diff --git a/pyproject.toml b/pyproject.toml
index 5b1ad5b4..8e3dd88d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,5 @@
[build-system]
-requires = ['setuptools>=77.0', 'Cython>=3.0.8', "poetry-core>=2.1.0"]
+requires = ['setuptools>=77.0', 'Cython==3.2.5', "poetry-core>=2.1.0"]
build-backend = "poetry.core.masonry.api"
[project]
diff --git a/setup.py b/setup.py
new file mode 100644
index 00000000..faff3b64
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,33 @@
+# flake8: noqa
+# ruff: noqa
+from setuptools import setup
+
+package_dir = {"": "src"}
+
+packages = ["zeroconf", "zeroconf._handlers", "zeroconf._protocol", "zeroconf._services", "zeroconf._utils"]
+
+package_data = {"": ["*"]}
+
+install_requires = ["ifaddr>=0.1.7"]
+
+setup_kwargs = {
+ "name": "zeroconf",
+ "version": "0.149.16",
+ "description": "A pure python implementation of multicast DNS service discovery",
+ "long_description": 'python-zeroconf\n===============\n\n.. image:: https://github.com/python-zeroconf/python-zeroconf/workflows/CI/badge.svg\n :target: https://github.com/python-zeroconf/python-zeroconf?query=workflow%3ACI+branch%3Amaster\n\n.. image:: https://img.shields.io/pypi/v/zeroconf.svg\n :target: https://pypi.python.org/pypi/zeroconf\n\n.. image:: https://codecov.io/gh/python-zeroconf/python-zeroconf/branch/master/graph/badge.svg\n :target: https://codecov.io/gh/python-zeroconf/python-zeroconf\n\n.. image:: https://img.shields.io/endpoint?url=https://codspeed.io/badge.json\n :target: https://codspeed.io/python-zeroconf/python-zeroconf\n :alt: Codspeed.io status for python-zeroconf\n\n.. image:: https://readthedocs.org/projects/python-zeroconf/badge/?version=latest\n :target: https://python-zeroconf.readthedocs.io/en/latest/?badge=latest\n :alt: Documentation Status\n\n`Documentation `_.\n\nThis is fork of pyzeroconf, Multicast DNS Service Discovery for Python,\noriginally by Paul Scott-Murphy (https://github.com/paulsm/pyzeroconf),\nmodified by William McBrine (https://github.com/wmcbrine/pyzeroconf).\n\nThe original William McBrine\'s fork note::\n\n This fork is used in all of my TiVo-related projects: HME for Python\n (and therefore HME/VLC), Network Remote, Remote Proxy, and pyTivo.\n Before this, I was tracking the changes for zeroconf.py in three\n separate repos. I figured I should have an authoritative source.\n\n Although I make changes based on my experience with TiVos, I expect that\n they\'re generally applicable. This version also includes patches found\n on the now-defunct (?) Launchpad repo of pyzeroconf, and elsewhere\n around the net -- not always well-documented, sorry.\n\nCompatible with:\n\n* Bonjour\n* Avahi\n\nCompared to some other Zeroconf/Bonjour/Avahi Python packages, python-zeroconf:\n\n* isn\'t tied to Bonjour or Avahi\n* doesn\'t use D-Bus\n* doesn\'t force you to use particular event loop or Twisted (asyncio is used under the hood but not required)\n* is pip-installable\n* has PyPI distribution\n* has an optional cython extension for performance (pure python is supported as well)\n\nPython compatibility\n--------------------\n\n* CPython 3.10+\n* PyPy 3.10+\n\nVersioning\n----------\n\nThis project uses semantic versioning.\n\nStatus\n------\n\nThis project is actively maintained.\n\nTraffic Reduction\n-----------------\n\nBefore version 0.32, most traffic reduction techniques described in https://datatracker.ietf.org/doc/html/rfc6762#section-7\nwhere not implemented which could lead to excessive network traffic. It is highly recommended that version 0.32 or later\nis used if this is a concern.\n\nIPv6 support\n------------\n\nIPv6 support is relatively new and currently limited, specifically:\n\n* `InterfaceChoice.All` is an alias for `InterfaceChoice.Default` on non-POSIX\n systems.\n* Dual-stack IPv6 sockets are used, which may not be supported everywhere (some\n BSD variants do not have them).\n* Listening on localhost (`::1`) does not work. Help with understanding why is\n appreciated.\n\nHow to get python-zeroconf?\n===========================\n\n* PyPI page https://pypi.org/project/zeroconf/\n* GitHub project https://github.com/python-zeroconf/python-zeroconf\n\nThe easiest way to install python-zeroconf is using pip::\n\n pip install zeroconf\n\n\n\nHow do I use it?\n================\n\nHere\'s an example of browsing for a service:\n\n.. code-block:: python\n\n from zeroconf import ServiceBrowser, ServiceListener, Zeroconf\n\n\n class MyListener(ServiceListener):\n\n def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:\n print(f"Service {name} updated")\n\n def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None:\n print(f"Service {name} removed")\n\n def add_service(self, zc: Zeroconf, type_: str, name: str) -> None:\n info = zc.get_service_info(type_, name)\n print(f"Service {name} added, service info: {info}")\n\n\n zeroconf = Zeroconf()\n listener = MyListener()\n browser = ServiceBrowser(zeroconf, "_http._tcp.local.", listener)\n try:\n input("Press enter to exit...\\n\\n")\n finally:\n zeroconf.close()\n\n.. note::\n\n Discovery and service registration use *all* available network interfaces by default.\n If you want to customize that you need to specify ``interfaces`` argument when\n constructing ``Zeroconf`` object (see the code for details).\n\nIf you don\'t know the name of the service you need to browse for, try:\n\n.. code-block:: python\n\n from zeroconf import ZeroconfServiceTypes\n print(\'\\n\'.join(ZeroconfServiceTypes.find()))\n\nSee examples directory for more.\n\nChangelog\n=========\n\n`Changelog `_\n\nLicense\n=======\n\nGNU Lesser General Public License v2.1 or later (LGPL-2.1-or-later).\n\nThe full text of LGPL 2.1 is included in the `COPYING `_ file.\nYou may, at your option, use this library under the terms of any later\nversion of the LGPL published by the Free Software Foundation. The\ncanonical SPDX identifier for this project is ``LGPL-2.1-or-later``, as\ndeclared in ``pyproject.toml``.\n',
+ "author": "Paul Scott-Murphy",
+ "author_email": "None",
+ "maintainer": "None",
+ "maintainer_email": "None",
+ "url": "https://github.com/python-zeroconf/python-zeroconf",
+ "package_dir": package_dir,
+ "packages": packages,
+ "package_data": package_data,
+ "install_requires": install_requires,
+ "python_requires": ">=3.10",
+}
+from build_ext import *
+
+build(setup_kwargs)
+
+setup(**setup_kwargs)
diff --git a/src/zeroconf/_cache.pxd b/src/zeroconf/_cache.pxd
index 023304bc..ab018ae4 100644
--- a/src/zeroconf/_cache.pxd
+++ b/src/zeroconf/_cache.pxd
@@ -88,7 +88,7 @@ cdef class DNSCache:
@cython.locals(record=DNSRecord, now=double)
cpdef current_entry_with_name_and_alias(self, str name, str alias)
- cpdef void _async_set_created_ttl(
+ cpdef DNSRecord _async_set_created_ttl(
self,
DNSRecord record,
double now,
diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py
index df60982b..262550de 100644
--- a/src/zeroconf/_cache.py
+++ b/src/zeroconf/_cache.py
@@ -340,9 +340,44 @@ 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: _int) -> None:
+ def _async_set_created_ttl(self, record: DNSRecord, now: _float, ttl: _int) -> DNSRecord:
"""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)
+ # The class_ attribute is stripped of the unique bit, so we must restore it
+ # 0x8000 is _CLASS_UNIQUE
+ original_class = record.class_ | (0x8000 if record.unique else 0)
+
+ new_record: DNSRecord
+ if isinstance(record, DNSAddress):
+ new_record = DNSAddress(
+ record.name, record.type, original_class, ttl, record.address, record.scope_id, now
+ )
+ elif isinstance(record, DNSHinfo):
+ new_record = DNSHinfo(record.name, record.type, original_class, ttl, record.cpu, record.os, now)
+ elif isinstance(record, DNSPointer):
+ new_record = DNSPointer(record.name, record.type, original_class, ttl, record.alias, now)
+ elif isinstance(record, DNSText):
+ new_record = DNSText(record.name, record.type, original_class, ttl, record.text, now)
+ elif isinstance(record, DNSService):
+ new_record = DNSService(
+ record.name,
+ record.type,
+ original_class,
+ ttl,
+ record.priority,
+ record.weight,
+ record.port,
+ record.server,
+ now,
+ )
+ elif isinstance(record, DNSNsec):
+ new_record = DNSNsec(
+ record.name, record.type, original_class, ttl, record.next_name, record.rdtypes, now
+ )
+ else: # pragma: no cover
+ new_record = type(record)(record.name, record.type, original_class, ttl, now)
+
+ store = self.cache.get(record.key)
+ if store is not None and record in store:
+ self.async_remove_records([record])
+ self._async_add(new_record)
+ return new_record
diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py
index 566f0e8c..e718b942 100644
--- a/src/zeroconf/_handlers/record_manager.py
+++ b/src/zeroconf/_handlers/record_manager.py
@@ -214,8 +214,5 @@ def async_remove_listener(self, listener: RecordUpdateListener) -> None:
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)
+ self.listeners.discard(listener)
+ self.zc.async_notify_all()
diff --git a/src/zeroconf/_logger.py b/src/zeroconf/_logger.py
index 99990cf6..1f000881 100644
--- a/src/zeroconf/_logger.py
+++ b/src/zeroconf/_logger.py
@@ -1,5 +1,4 @@
"""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/src/zeroconf/_services/registry.pxd b/src/zeroconf/_services/registry.pxd
index 6f9017db..216e2288 100644
--- a/src/zeroconf/_services/registry.pxd
+++ b/src/zeroconf/_services/registry.pxd
@@ -12,7 +12,7 @@ cdef class ServiceRegistry:
cdef public bint has_entries
@cython.locals(
- record_list=cython.list,
+ record_list=cython.dict,
)
cdef cython.list _async_get_by_index(self, cython.dict records, str key)
diff --git a/src/zeroconf/_services/registry.py b/src/zeroconf/_services/registry.py
index 937992eb..b6961322 100644
--- a/src/zeroconf/_services/registry.py
+++ b/src/zeroconf/_services/registry.py
@@ -42,8 +42,8 @@ def __init__(
) -> None:
"""Create the ServiceRegistry class."""
self._services: dict[str, ServiceInfo] = {}
- self.types: dict[str, list] = {}
- self.servers: dict[str, list] = {}
+ self.types: dict[str, dict[str, None]] = {}
+ self.servers: dict[str, dict[str, None]] = {}
self.has_entries: bool = False
def async_add(self, info: ServiceInfo) -> None:
@@ -79,7 +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, dict[str, None]], key: _str) -> list[ServiceInfo]:
"""Return all ServiceInfo matching the index."""
record_list = records.get(key)
if record_list is None:
@@ -94,8 +94,8 @@ def _add(self, info: ServiceInfo) -> None:
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)
+ self.types.setdefault(info.type.lower(), {})[info.key] = None
+ self.servers.setdefault(info.server_key, {})[info.key] = None
self.has_entries = True
def _remove(self, infos: list[ServiceInfo]) -> None:
@@ -105,8 +105,8 @@ def _remove(self, infos: list[ServiceInfo]) -> None:
if old_service_info is None:
continue
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)
+ self.types[old_service_info.type.lower()].pop(info.key, None)
+ self.servers[old_service_info.server_key].pop(info.key, None)
del self._services[info.key]
self.has_entries = bool(self._services)
diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py
index 28b3d12e..24f26f36 100644
--- a/tests/services/test_browser.py
+++ b/tests/services/test_browser.py
@@ -1562,7 +1562,7 @@ 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.values():
+ for record in list(cache_record.values()):
zc.cache._async_set_created_ttl(record, now, 1)
# Wait for the add callback to fire from the original inject_response.
diff --git a/tests/services/test_info.py b/tests/services/test_info.py
index 219f5226..7db86290 100644
--- a/tests/services/test_info.py
+++ b/tests/services/test_info.py
@@ -10,12 +10,13 @@
import unittest
from ipaddress import ip_address
from threading import Event
+from typing import cast
from unittest.mock import patch
import pytest
import zeroconf as r
-from zeroconf import DNSAddress, RecordUpdate, const
+from zeroconf import DNSAddress, DNSText, RecordUpdate, const
from zeroconf._protocol.outgoing import DNSOutgoing
from zeroconf._services import info
from zeroconf._services.info import ServiceInfo, _has_more_scope_info
@@ -245,7 +246,7 @@ def test_service_info_rejects_expired_records(self):
ttl,
b"\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==",
)
- zc.cache._async_set_created_ttl(expired_record, 1000, 1)
+ expired_record = cast(DNSText, 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 aeb3a2ab..92a79966 100644
--- a/tests/test_cache.py
+++ b/tests/test_cache.py
@@ -719,3 +719,23 @@ def actual() -> int:
cache.async_add_records([rec])
assert cache._total_records == actual()
assert cache._total_records == const._MAX_CACHE_RECORDS
+
+
+def test_cache_async_set_created_ttl_dnsnsec():
+ from zeroconf._cache import DNSCache # noqa: PLC0415
+ from zeroconf._dns import DNSHinfo, DNSNsec # noqa: PLC0415
+ from zeroconf.const import _CLASS_IN # noqa: PLC0415
+
+ record = DNSNsec("test.local.", 47, _CLASS_IN, 100, "next.local.", [1, 2, 3])
+ cache = DNSCache()
+ cache.async_add_records([record])
+ new_record = cache._async_set_created_ttl(record, 10.0, 50)
+ assert isinstance(new_record, DNSNsec)
+ assert new_record.ttl == 50
+
+ # DNSHinfo coverage
+ hinfo_record = DNSHinfo("test-hinfo.local.", 13, _CLASS_IN, 100, "cpu", "os")
+ cache.async_add_records([hinfo_record])
+ new_hinfo = cache._async_set_created_ttl(hinfo_record, 10.0, 50)
+ assert isinstance(new_hinfo, DNSHinfo)
+ assert new_hinfo.ttl == 50
diff --git a/tests/test_handlers.py b/tests/test_handlers.py
index 69f3c826..d2a88822 100644
--- a/tests/test_handlers.py
+++ b/tests/test_handlers.py
@@ -15,7 +15,7 @@
import pytest
import zeroconf as r
-from zeroconf import ServiceInfo, Zeroconf, const, current_time_millis
+from zeroconf import DNSAddress, DNSPointer, ServiceInfo, Zeroconf, const, current_time_millis
from zeroconf._handlers.multicast_outgoing_queue import (
MulticastOutgoingQueue,
construct_outgoing_multicast_answers,
@@ -1149,7 +1149,12 @@ 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]
- zc.cache._async_set_created_ttl(a_record, current_time_millis() - (a_record.ttl * 1000 / 2), a_record.ttl)
+ a_record = cast(
+ DNSAddress,
+ 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
@@ -1199,8 +1204,11 @@ 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])
- zc.cache._async_set_created_ttl(
- ptr_record, current_time_millis() - (ptr_record.ttl * 1000 / 2), ptr_record.ttl
+ ptr_record = cast(
+ DNSPointer,
+ 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())
# With QU should respond to only multicast since the has less
@@ -1318,7 +1326,8 @@ 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))
- assert original_a_record.ttl == 1
+ unique_a = zc.cache.async_get_unique(a_record)
+ assert unique_a is not None and unique_a.ttl == 1
for record in new_records:
assert zc.cache.async_get_unique(record) is not None