diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a3f69935afa2809c79ae5bd1bf2bd1728006978..19b36d46b5043ac1f767ee1b520322e33895f198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,50 @@ # 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 + +- 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 + +- 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 + +- 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 + +- 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/PKG-INFO b/PKG-INFO index daa5a466fdffc7b274e408cc9c0f24fcbd626c92..079aeb5578a749257263e24a4041d7f7efb9ef00 100644 --- a/PKG-INFO +++ b/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.3 Name: zeroconf -Version: 0.143.0 +Version: 0.144.3 Summary: A pure python implementation of multicast DNS service discovery License: LGPL-2.1-or-later Author: Paul Scott-Murphy diff --git a/build_ext.py b/build_ext.py index 26b4eb96f3910ad3c385ce475ca5f773fb606c12..e91f6350f35ac276ab75b8de00b83cbc05335553 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 72728b5ba94fc550139499cab7f2a8b6e8f2f72c..4015b88f62bc2c4aaead90e9ad8397dea8c19547 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.143.0" +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" @@ -86,13 +86,31 @@ 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] 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" @@ -162,15 +239,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/*', @@ -198,5 +281,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 diff --git a/setup.py b/setup.py index bcd677386bb6697f9975aecf5a630b0a9fae040f..2d0b41f92314b65e1b51d89ba39d622872348def 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ install_requires = \ setup_kwargs = { 'name': 'zeroconf', - 'version': '0.143.0', + 'version': '0.144.3', '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 <https://python-zeroconf.readthedocs.io/en/latest/>`_.\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.9+\n* PyPy 3.9+\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 <CHANGELOG.md>`_\n\nLicense\n=======\n\nLGPL, see COPYING file for details.\n', 'author': 'Paul Scott-Murphy', diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index b85aee743f9d6bbf5ac20fbdce517e0287e2241e..59052702e7ad4aadfa9617eb7c5c8668f76a8644 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ from ._utils.time import ( # noqa # import needed for backwards compat __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak <jakub@stasiak.at>" -__version__ = "0.143.0" +__version__ = "0.144.3" __license__ = "LGPL" diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index 5ac43f30736dd5d7fe98d74480d01203c211b9db..c8e2686ee3417ad0319f6ed34cdbdbf0dc0d0df3 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -22,8 +22,9 @@ USA 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 @@ 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]] +_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 3f007c1743aa77849c5f8a0d484abce991eb4d3d..5e3a7f46518daada87ffa921a28d109998f39d2e 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -26,8 +26,8 @@ import asyncio 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/_dns.py b/src/zeroconf/_dns.py index bc0a3948e534c4eab63817d1716d6ca09612d50e..591eb0183a0d3519a65fadc862765be4ebd87de0 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -79,7 +79,7 @@ class DNSEntry: 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 @@ class DNSQuestion(DNSEntry): @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 @@ class DNSRecord(DNSEntry): 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 @@ class DNSAddress(DNSRecord): """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 @@ class DNSHinfo(DNSRecord): """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 @@ class DNSPointer(DNSRecord): """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 @@ class DNSText(DNSRecord): """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 @@ class DNSService(DNSRecord): """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 @@ class DNSNsec(DNSRecord): """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/_handlers/answers.py b/src/zeroconf/_handlers/answers.py index ec53eb84295035473ce197baca4a17e790e5e15f..07b0a65ab96b4769d5aae39fbdd6e2320e2df1a9 100644 --- a/src/zeroconf/_handlers/answers.py +++ b/src/zeroconf/_handlers/answers.py @@ -23,13 +23,12 @@ USA 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/_history.py b/src/zeroconf/_history.py index 5bae7be048ce0f9d8cd0d45a0fe49078fd163161..1b6f3fadf2ada5c3f748b81d6f542fb1cfe4d962 100644 --- a/src/zeroconf/_history.py +++ b/src/zeroconf/_history.py @@ -60,9 +60,7 @@ class QuestionHistory: 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/_listener.py b/src/zeroconf/_listener.py index 925c689e0ce6ec3f24ae0f672516de0038feb5ad..ed50316985e5636b508e7e24d2cd9ec98e2cf655 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -26,7 +26,7 @@ import asyncio 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 @@ -131,14 +131,14 @@ class AsyncListener: 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) + 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/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 7f4a8eec1c71c1058031dc213bfad7b6a3d5a4c2..2d977b642e99b32cf8166ca29c589835843e9d4a 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -398,7 +398,7 @@ class DNSIncoming: 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 f5d0982110e1c7cffae7a465f26fd8925a93aab5..fd5e57a023ab084d6709cbc9a3decc517ab931fe 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -24,8 +24,9 @@ from __future__ import annotations 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 @@ -271,7 +272,7 @@ class DNSOutgoing: """ # 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 5f817511363d41155ba398feeef32f02cadee50e..497ee39df141af1a6a404058ee225470251b6f89 100644 --- a/src/zeroconf/_record_update.py +++ b/src/zeroconf/_record_update.py @@ -43,6 +43,6 @@ class RecordUpdate: """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 6936aed61cf09d0f217be41227a7225196d5c01c..b244552f1b38fd6362c9a237c56fcc4d4f0e16b5 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/browser.py b/src/zeroconf/_services/browser.py index c2ab115b0a1c00a486c225becd111e823f8266f5..ab8c050d9d53bdf7e2de3463dbced7d2564797dc 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -29,16 +29,13 @@ import random 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 @@ int_ = int 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 @@ class _ServiceBrowserBase(RecordUpdateListener): 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 67777459403eb52737bb80cd32872b0dd5dadcad..9cd8df163fc0b2155181526c512852eca8d2fcd6 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -24,7 +24,7 @@ from __future__ import annotations 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 ( @@ -378,13 +378,13 @@ class ServiceInfo(RecordUpdateListener): 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) @@ -395,7 +395,7 @@ class ServiceInfo(RecordUpdateListener): # 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 @@ class ServiceInfo(RecordUpdateListener): """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 @@ class ServiceInfo(RecordUpdateListener): """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: @@ -524,7 +524,7 @@ class ServiceInfo(RecordUpdateListener): # 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 @@ class ServiceInfo(RecordUpdateListener): # 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) @@ -724,7 +724,7 @@ class ServiceInfo(RecordUpdateListener): 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 c92d99d5624e66cf5c2264691c849fb9116af39a..8609060179b352403dea4a2af46f53d9a3b509bf 100644 --- a/src/zeroconf/_utils/asyncio.py +++ b/src/zeroconf/_utils/asyncio.py @@ -26,7 +26,8 @@ import asyncio 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 3cc4336bf88ffa91fda607e3f36b7e2c7f4dc935..c2312e01fc81195f131459152f16a12e4d911b8f 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -28,7 +28,8 @@ import ipaddress 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 @@ -73,40 +74,41 @@ 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] ) -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}") @@ -126,9 +128,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 @@ -260,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 @@ -301,6 +322,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 " @@ -340,7 +375,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 @@ -399,8 +434,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]) diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index ce5a43eb9a16d3e4f7ed5ca82c4d7daddc0c67ba..a0f4a99db150bff68ccfd48e9ff0191674fed83d 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -24,8 +24,9 @@ from __future__ import annotations 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 1f323785c2410ec333e532c449a6fcfdcb3b3422..531c810befc48ad4d83ddafeb49c84094559c332 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_handlers.py b/tests/test_handlers.py index fd0e689c46f9fabb77239e7063e4c02d20c9155e..ffa4ff88cfcef796f12c255fe5d8165d834705b4 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -67,10 +67,9 @@ class TestRegistrar(unittest.TestCase): 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 4c9836ce6285ac285a0bae65542d7962e1d91b4d..e9254168e1d29c9a62d17c78bbf91df5cfbb1586 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_init.py b/tests/test_init.py index a36ff8fd286e1f4d0e317a1b8e75ab198aa08593..5ccb9ef634a7663d245ce1d9e71334c98e86c39b 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -89,9 +89,10 @@ class Names(unittest.TestCase): # 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 a55fc14352e550769bd536e0118e90d7599a212f..4897eabe0a6a45588cc875ef75d7023ec800873c 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 aa5b5382b0de6ac352040eb477abe7ff224d13e7..4e09aa3b1f3636c34941bddca26e1f0dc3d39132 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/test_services.py b/tests/test_services.py index e93174cc72e7f3e36309830e91464f8e74e83cf8..7d7c3fc7d79968ba53369de057d4f829ed007c6b 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -71,7 +71,6 @@ class ListenerTest(unittest.TestCase): class MySubListener(r.ServiceListener): def add_service(self, zeroconf, type, name): sub_service_added.set() - pass def remove_service(self, zeroconf, type, name): pass diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 489a6460c6c2fbf477fa3f9cc940399dbc1cb5d5..f763b655c22598250b546292c2bea3a58cc92a2b 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -4,6 +4,7 @@ from __future__ import annotations import errno import socket +import sys import unittest from unittest.mock import MagicMock, Mock, patch @@ -82,9 +83,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 +131,10 @@ def test_disable_ipv6_only_or_raise(): 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) @@ -177,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" @@ -217,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.""" @@ -235,6 +260,40 @@ def test_bind_raises_skips_address(): 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):