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