zeroconf 0.152.0__tar.gz → 0.153.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. {zeroconf-0.152.0 → zeroconf-0.153.0}/CHANGELOG.md +17 -0
  2. {zeroconf-0.152.0 → zeroconf-0.153.0}/PKG-INFO +4 -5
  3. {zeroconf-0.152.0 → zeroconf-0.153.0}/README.rst +2 -2
  4. {zeroconf-0.152.0 → zeroconf-0.153.0}/pyproject.toml +7 -7
  5. {zeroconf-0.152.0 → zeroconf-0.153.0}/setup.py +3 -3
  6. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/__init__.py +1 -1
  7. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_cache.pxd +3 -0
  8. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_cache.py +15 -0
  9. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_core.py +2 -1
  10. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_dns.pxd +2 -0
  11. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_dns.py +5 -0
  12. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_handlers/record_manager.pxd +5 -1
  13. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_handlers/record_manager.py +18 -2
  14. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_listener.pxd +3 -1
  15. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_listener.py +19 -7
  16. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_protocol/incoming.pxd +1 -0
  17. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_protocol/incoming.py +4 -2
  18. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_services/browser.py +2 -1
  19. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_services/info.py +3 -3
  20. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_utils/asyncio.py +1 -2
  21. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/asyncio.py +3 -2
  22. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/__init__.py +27 -1
  23. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/conftest.py +2 -2
  24. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/services/test_browser.py +38 -0
  25. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/services/test_info.py +19 -0
  26. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_handlers.py +66 -1
  27. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_listener.py +60 -1
  28. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_protocol.py +8 -1
  29. {zeroconf-0.152.0 → zeroconf-0.153.0}/COPYING +0 -0
  30. {zeroconf-0.152.0 → zeroconf-0.153.0}/build_ext.py +0 -0
  31. {zeroconf-0.152.0 → zeroconf-0.153.0}/docs/Makefile +0 -0
  32. {zeroconf-0.152.0 → zeroconf-0.153.0}/docs/_ext/zeroconfautodocfix.py +0 -0
  33. {zeroconf-0.152.0 → zeroconf-0.153.0}/docs/api.rst +0 -0
  34. {zeroconf-0.152.0 → zeroconf-0.153.0}/docs/conf.py +0 -0
  35. {zeroconf-0.152.0 → zeroconf-0.153.0}/docs/index.rst +0 -0
  36. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_engine.py +0 -0
  37. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_exceptions.py +0 -0
  38. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_handlers/__init__.py +0 -0
  39. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_handlers/answers.pxd +0 -0
  40. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_handlers/answers.py +0 -0
  41. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_handlers/multicast_outgoing_queue.pxd +0 -0
  42. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_handlers/multicast_outgoing_queue.py +0 -0
  43. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_handlers/query_handler.pxd +0 -0
  44. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_handlers/query_handler.py +0 -0
  45. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_history.pxd +0 -0
  46. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_history.py +0 -0
  47. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_logger.py +0 -0
  48. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_protocol/__init__.py +0 -0
  49. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_protocol/outgoing.pxd +0 -0
  50. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_protocol/outgoing.py +0 -0
  51. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_record_update.pxd +0 -0
  52. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_record_update.py +0 -0
  53. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_services/__init__.pxd +0 -0
  54. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_services/__init__.py +0 -0
  55. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_services/browser.pxd +0 -0
  56. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_services/info.pxd +0 -0
  57. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_services/registry.pxd +0 -0
  58. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_services/registry.py +0 -0
  59. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_services/types.py +0 -0
  60. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_transport.py +0 -0
  61. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_updates.pxd +0 -0
  62. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_updates.py +0 -0
  63. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_utils/__init__.py +0 -0
  64. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_utils/ipaddress.pxd +0 -0
  65. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_utils/ipaddress.py +0 -0
  66. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_utils/name.py +0 -0
  67. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_utils/net.py +0 -0
  68. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_utils/time.pxd +0 -0
  69. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_utils/time.py +0 -0
  70. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/const.py +0 -0
  71. {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/py.typed +0 -0
  72. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/__init__.py +0 -0
  73. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/helpers.py +0 -0
  74. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_cache.py +0 -0
  75. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_cache_bound.py +0 -0
  76. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_cache_mark_expire.py +0 -0
  77. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_incoming.py +0 -0
  78. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_ipaddress.py +0 -0
  79. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_listener_dedup.py +0 -0
  80. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_mark_seen.py +0 -0
  81. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_outgoing.py +0 -0
  82. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_registry.py +0 -0
  83. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_send.py +0 -0
  84. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_txt_properties.py +0 -0
  85. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/services/__init__.py +0 -0
  86. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/services/test_registry.py +0 -0
  87. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/services/test_types.py +0 -0
  88. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_asyncio.py +0 -0
  89. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_cache.py +0 -0
  90. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_circular_imports.py +0 -0
  91. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_core.py +0 -0
  92. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_dns.py +0 -0
  93. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_engine.py +0 -0
  94. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_exceptions.py +0 -0
  95. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_fuzz_incoming.py +0 -0
  96. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_history.py +0 -0
  97. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_init.py +0 -0
  98. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_interface_update.py +0 -0
  99. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_logger.py +0 -0
  100. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_services.py +0 -0
  101. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_updates.py +0 -0
  102. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/utils/__init__.py +0 -0
  103. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/utils/test_asyncio.py +0 -0
  104. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/utils/test_ipaddress.py +0 -0
  105. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/utils/test_name.py +0 -0
  106. {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/utils/test_net.py +0 -0
@@ -2,6 +2,23 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v0.153.0 (2026-10-31)
6
+
7
+ ### Features
8
+
9
+ - Drop Python 3.10 support ([#1935](https://github.com/python-zeroconf/python-zeroconf/pull/1935),
10
+ [`7fd81ac`](https://github.com/python-zeroconf/python-zeroconf/commit/7fd81ac5d3ce1846671e9b8e1fab0b425b112f67))
11
+
12
+
13
+ ## v0.152.1 (2026-10-31)
14
+
15
+ ### Bug Fixes
16
+
17
+ - Don't suppress a scoped copy of a packet as a duplicate of an unscoped one
18
+ ([#1926](https://github.com/python-zeroconf/python-zeroconf/pull/1926),
19
+ [`737c667`](https://github.com/python-zeroconf/python-zeroconf/commit/737c6675a66428624b0c2382d460cd600f11838e))
20
+
21
+
5
22
  ## v0.152.0 (2026-10-31)
6
23
 
7
24
  ### Features
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zeroconf
3
- Version: 0.152.0
3
+ Version: 0.153.0
4
4
  Summary: A pure python implementation of multicast DNS service discovery
5
5
  License-Expression: LGPL-2.1-or-later
6
6
  License-File: COPYING
7
7
  Author: The python-zeroconf authors
8
8
  Maintainer: J. Nick Koston
9
9
  Maintainer-email: nick@koston.org
10
- Requires-Python: >=3.10
10
+ Requires-Python: >=3.11
11
11
  Classifier: Development Status :: 5 - Production/Stable
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: Intended Audience :: System Administrators
@@ -15,7 +15,6 @@ Classifier: Operating System :: MacOS :: MacOS X
15
15
  Classifier: Operating System :: POSIX
16
16
  Classifier: Operating System :: POSIX :: Linux
17
17
  Classifier: Programming Language :: Python :: 3
18
- Classifier: Programming Language :: Python :: 3.10
19
18
  Classifier: Programming Language :: Python :: 3.11
20
19
  Classifier: Programming Language :: Python :: 3.12
21
20
  Classifier: Programming Language :: Python :: 3.13
@@ -74,8 +73,8 @@ Compared to some other Zeroconf/Bonjour/Avahi Python packages, python-zeroconf:
74
73
  Python compatibility
75
74
  --------------------
76
75
 
77
- * CPython 3.10+
78
- * PyPy 3.10+
76
+ * CPython 3.11+
77
+ * PyPy 3.11+
79
78
 
80
79
  Versioning
81
80
  ----------
@@ -41,8 +41,8 @@ Compared to some other Zeroconf/Bonjour/Avahi Python packages, python-zeroconf:
41
41
  Python compatibility
42
42
  --------------------
43
43
 
44
- * CPython 3.10+
45
- * PyPy 3.10+
44
+ * CPython 3.11+
45
+ * PyPy 3.11+
46
46
 
47
47
  Versioning
48
48
  ----------
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "zeroconf"
7
- version = "0.152.0"
7
+ version = "0.153.0"
8
8
  license = "LGPL-2.1-or-later"
9
9
  description = "A pure python implementation of multicast DNS service discovery"
10
10
  readme = "README.rst"
@@ -14,7 +14,7 @@ authors = [
14
14
  maintainers = [
15
15
  { name = "J. Nick Koston", email = "nick@koston.org" },
16
16
  ]
17
- requires-python = ">=3.10"
17
+ requires-python = ">=3.11"
18
18
 
19
19
  [project.urls]
20
20
  "Repository" = "https://github.com/python-zeroconf/python-zeroconf"
@@ -80,13 +80,13 @@ match = "(?!(master|release-0.x)$)"
80
80
  prerelease = true
81
81
 
82
82
  [tool.poetry.dependencies]
83
- python = "^3.10"
83
+ python = "^3.11"
84
84
  ifaddr = ">=0.1.7"
85
85
 
86
86
  [tool.poetry.group.dev.dependencies]
87
- pytest = ">=9.1.1,<10.0"
87
+ pytest = ">=9.2.0,<10.0"
88
88
  pytest-cov = ">=4,<8"
89
- pytest-asyncio = ">=1.4.0,<1.5.0"
89
+ pytest-asyncio = ">=1.4.1,<1.5.0"
90
90
  cython = "^3.3.0"
91
91
  setuptools = ">=84.2.1,<85.0.0"
92
92
  pytest-timeout = "^2.1.0"
@@ -99,7 +99,7 @@ sphinx = "^7.4.7 || ^8.1.3"
99
99
  sphinx-rtd-theme = "^3.1.0"
100
100
 
101
101
  [tool.ruff]
102
- target-version = "py310"
102
+ target-version = "py311"
103
103
  line-length = 110
104
104
 
105
105
  [tool.ruff.lint]
@@ -117,10 +117,10 @@ ignore = [
117
117
  "PGH004", # too many to fix
118
118
  "PGH003", # too many to fix
119
119
  "SIM110", # this is slower
120
- "PYI034", # enable when we drop Py3.10
121
120
  "PYI032", # breaks Cython
122
121
  "PYI041", # breaks Cython
123
122
  "PERF401", # Cython: closures inside cpdef functions not yet supported
123
+ "ASYNC109", # timeout params are public API
124
124
  ]
125
125
  select = [
126
126
  "ASYNC", # async rules
@@ -19,9 +19,9 @@ install_requires = \
19
19
 
20
20
  setup_kwargs = {
21
21
  'name': 'zeroconf',
22
- 'version': '0.152.0',
22
+ 'version': '0.153.0',
23
23
  'description': 'A pure python implementation of multicast DNS service discovery',
24
- '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 project is a fork of pyzeroconf, originally by Paul Scott-Murphy\n(https://github.com/paulsm/pyzeroconf), modified by William McBrine\n(https://github.com/wmcbrine/pyzeroconf).\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 how to discover services:\n\n.. code-block:: python\n\n from zeroconf import ServiceBrowser, ServiceListener, Zeroconf\n\n\n class EventLogger(ServiceListener):\n def add_service(self, zc: Zeroconf, type_: str, name: str) -> None:\n info = zc.get_service_info(type_, name)\n print(f"discovered {name}: {info}")\n\n def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None:\n print(f"lost {name}")\n\n def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:\n print(f"refreshed {name}")\n\n\n with Zeroconf() as zc:\n ServiceBrowser(zc, "_http._tcp.local.", EventLogger())\n input("browsing, press enter to stop\\n")\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\nTo see which service types are present on the network, 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\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 <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',
24
+ '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 project is a fork of pyzeroconf, originally by Paul Scott-Murphy\n(https://github.com/paulsm/pyzeroconf), modified by William McBrine\n(https://github.com/wmcbrine/pyzeroconf).\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.11+\n* PyPy 3.11+\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 how to discover services:\n\n.. code-block:: python\n\n from zeroconf import ServiceBrowser, ServiceListener, Zeroconf\n\n\n class EventLogger(ServiceListener):\n def add_service(self, zc: Zeroconf, type_: str, name: str) -> None:\n info = zc.get_service_info(type_, name)\n print(f"discovered {name}: {info}")\n\n def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None:\n print(f"lost {name}")\n\n def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:\n print(f"refreshed {name}")\n\n\n with Zeroconf() as zc:\n ServiceBrowser(zc, "_http._tcp.local.", EventLogger())\n input("browsing, press enter to stop\\n")\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\nTo see which service types are present on the network, 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\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 <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',
25
25
  'author': 'The python-zeroconf authors',
26
26
  'author_email': 'None',
27
27
  'maintainer': 'J. Nick Koston',
@@ -31,7 +31,7 @@ setup_kwargs = {
31
31
  'packages': packages,
32
32
  'package_data': package_data,
33
33
  'install_requires': install_requires,
34
- 'python_requires': '>=3.10',
34
+ 'python_requires': '>=3.11',
35
35
  }
36
36
  from build_ext import *
37
37
  build(setup_kwargs)
@@ -73,7 +73,7 @@ from ._utils.time import ( # noqa # import needed for backwards compat
73
73
 
74
74
  __author__ = "The python-zeroconf authors" # full history in COPYING and git
75
75
  __maintainer__ = "J. Nick Koston <nick@koston.org>"
76
- __version__ = "0.152.0"
76
+ __version__ = "0.153.0"
77
77
  __license__ = "LGPL"
78
78
 
79
79
 
@@ -38,6 +38,9 @@ cdef class DNSCache:
38
38
 
39
39
  cpdef void async_remove_records(self, object entries)
40
40
 
41
+ @cython.locals(store=cython.dict)
42
+ cpdef DNSAddress async_get_address(self, DNSAddress entry)
43
+
41
44
  @cython.locals(store=cython.dict)
42
45
  cpdef DNSRecord async_get_unique(self, DNSRecord entry)
43
46
 
@@ -131,6 +131,21 @@ class DNSCache:
131
131
  self.async_remove_records(expired)
132
132
  return expired
133
133
 
134
+ def async_get_address(self, entry: DNSAddress) -> DNSAddress | None:
135
+ """Look up the cached copy of an address record under any scope, or None."""
136
+ store = self.cache.get(entry.key)
137
+ if store is None:
138
+ return None
139
+ for record in store.values():
140
+ if (
141
+ isinstance(record, DNSAddress)
142
+ and record.address == entry.address
143
+ and record.type == entry.type
144
+ and record.class_ == entry.class_
145
+ ):
146
+ return record
147
+ return None
148
+
134
149
  def async_get_unique(self, entry: _UniqueRecordsType) -> DNSRecord | None:
135
150
  """Look up the cached copy of a unique record, or None.
136
151
 
@@ -14,6 +14,7 @@ import sys
14
14
  import threading
15
15
  from collections.abc import Awaitable
16
16
  from types import TracebackType
17
+ from typing import Self
17
18
 
18
19
  from ._cache import DNSCache
19
20
  from ._dns import DNSQuestion, DNSQuestionType
@@ -212,7 +213,7 @@ class Zeroconf(QuietLogger):
212
213
 
213
214
  self.start()
214
215
 
215
- def __enter__(self) -> Zeroconf:
216
+ def __enter__(self) -> Self:
216
217
  return self
217
218
 
218
219
  def __exit__( # pylint: disable=useless-return
@@ -78,6 +78,8 @@ cdef class DNSAddress(DNSRecord):
78
78
 
79
79
  cdef bint _eq(self, DNSAddress other)
80
80
 
81
+ cdef void _set_scope_id(self, object scope_id)
82
+
81
83
  cpdef write(self, DNSOutgoing out)
82
84
 
83
85
 
@@ -310,6 +310,11 @@ class DNSAddress(DNSRecord):
310
310
  self.scope_id = scope_id
311
311
  self._hash = hash((self.key, type_, self.class_, address, scope_id))
312
312
 
313
+ def _set_scope_id(self, scope_id: _int | None) -> None:
314
+ """Replace the scope of a record that is not cached yet."""
315
+ self.scope_id = scope_id
316
+ self._hash = hash((self.key, self.type, self.class_, self.address, scope_id))
317
+
313
318
 
314
319
  class DNSHinfo(DNSRecord):
315
320
  """HINFO record carrying the host's cpu and os strings."""
@@ -2,7 +2,7 @@
2
2
  import cython
3
3
 
4
4
  from .._cache cimport DNSCache
5
- from .._dns cimport DNSQuestion, DNSRecord
5
+ from .._dns cimport DNSAddress, DNSQuestion, DNSRecord
6
6
  from .._protocol.incoming cimport DNSIncoming
7
7
  from .._updates cimport RecordUpdateListener
8
8
  from .._utils.time cimport current_time_millis
@@ -10,6 +10,7 @@ from .._record_update cimport RecordUpdate
10
10
 
11
11
  cdef unsigned int _DNS_PTR_MIN_TTL
12
12
  cdef cython.uint _TYPE_PTR
13
+ cdef cython.uint _TYPE_AAAA
13
14
  cdef object _ADDRESS_RECORD_TYPES
14
15
  cdef bint TYPE_CHECKING
15
16
  cdef object _TYPE_PTR
@@ -38,5 +39,8 @@ cdef class RecordManager:
38
39
 
39
40
  cpdef void async_remove_listener(self, RecordUpdateListener listener)
40
41
 
42
+ @cython.locals(cached=DNSAddress)
43
+ cdef DNSAddress _async_merge_address(self, DNSAddress record)
44
+
41
45
  @cython.locals(question=DNSQuestion, record=DNSRecord)
42
46
  cdef void _async_update_matching_records(self, RecordUpdateListener listener, cython.list questions)
@@ -10,13 +10,13 @@ from __future__ import annotations
10
10
  from typing import TYPE_CHECKING, cast
11
11
 
12
12
  from .._cache import _UniqueRecordsType
13
- from .._dns import DNSQuestion, DNSRecord
13
+ from .._dns import DNSAddress, DNSQuestion, DNSRecord
14
14
  from .._logger import log
15
15
  from .._protocol.incoming import DNSIncoming
16
16
  from .._record_update import RecordUpdate
17
17
  from .._updates import RecordUpdateListener
18
18
  from .._utils.time import current_time_millis
19
- from ..const import _ADDRESS_RECORD_TYPES, _DNS_PTR_MIN_TTL, _TYPE_PTR
19
+ from ..const import _ADDRESS_RECORD_TYPES, _DNS_PTR_MIN_TTL, _TYPE_AAAA, _TYPE_PTR
20
20
 
21
21
  if TYPE_CHECKING:
22
22
  from .._core import Zeroconf
@@ -118,6 +118,11 @@ class RecordManager:
118
118
  record = cast(_UniqueRecordsType, record)
119
119
 
120
120
  maybe_entry = cache.async_get_unique(record)
121
+ if maybe_entry is None and record_type == _TYPE_AAAA:
122
+ if TYPE_CHECKING:
123
+ assert isinstance(record, DNSAddress)
124
+ maybe_entry = self._async_merge_address(record)
125
+
121
126
  if not record.is_expired(now):
122
127
  if record_type in _ADDRESS_RECORD_TYPES:
123
128
  address_adds.append(record)
@@ -167,6 +172,17 @@ class RecordManager:
167
172
  if updates:
168
173
  self.async_updates_complete(new)
169
174
 
175
+ def _async_merge_address(self, record: DNSAddress) -> DNSAddress | None:
176
+ """Return the cached copy of the AAAA, keeping one per address and preferring a scoped one."""
177
+ cached = self.cache.async_get_address(record)
178
+ if cached is None:
179
+ return None
180
+ if record.scope_id and not cached.scope_id and record.ttl:
181
+ self.cache.async_remove_records((cached,))
182
+ return None
183
+ record._set_scope_id(cached.scope_id)
184
+ return cached
185
+
170
186
  def _async_update_matching_records(
171
187
  self, listener: RecordUpdateListener, questions: list[_DNSQuestion]
172
188
  ) -> None:
@@ -17,6 +17,7 @@ cdef cython.uint _DUPLICATE_PACKET_SUPPRESSION_INTERVAL
17
17
  cdef cython.uint _RECENT_PACKETS_MAX
18
18
  cdef cython.uint _MAX_DEFERRED_ADDRS
19
19
  cdef cython.uint _MAX_DEFERRED_PER_ADDR
20
+ cdef tuple _NOT_SEEN
20
21
 
21
22
 
22
23
  cdef class AsyncListener:
@@ -27,6 +28,7 @@ cdef class AsyncListener:
27
28
  cdef QueryHandler _query_handler
28
29
  cdef public cython.bytes data
29
30
  cdef public double last_time
31
+ cdef bint _last_scoped
30
32
  cdef public DNSIncoming last_message
31
33
  cdef public object transport
32
34
  cdef public object sock_description
@@ -38,7 +40,7 @@ cdef class AsyncListener:
38
40
  @cython.locals(now=double, debug=cython.bint)
39
41
  cpdef datagram_received(self, cython.bytes bytes, cython.tuple addrs)
40
42
 
41
- @cython.locals(msg=DNSIncoming, recent_time=double)
43
+ @cython.locals(msg=DNSIncoming, recent=tuple, recent_time=double, scoped=bint)
42
44
  cpdef _process_datagram_at_time(self, bint debug, cython.uint data_len, double now, bytes data, cython.tuple addrs)
43
45
 
44
46
  cdef _cancel_any_timers_for_addr(self, object addr)
@@ -38,6 +38,8 @@ _float = float
38
38
 
39
39
  DEBUG_ENABLED = partial(log.isEnabledFor, logging.DEBUG)
40
40
 
41
+ _NOT_SEEN = (-1e30, True)
42
+
41
43
 
42
44
  class AsyncListener:
43
45
  """Asyncio datagram protocol that receives mDNS packets and feeds them
@@ -46,6 +48,7 @@ class AsyncListener:
46
48
  __slots__ = (
47
49
  "_deferred",
48
50
  "_deferred_deadlines",
51
+ "_last_scoped",
49
52
  "_query_handler",
50
53
  "_recent_packets",
51
54
  "_record_manager",
@@ -67,6 +70,7 @@ class AsyncListener:
67
70
  self.data: bytes | None = None
68
71
  self.last_time: float = 0
69
72
  self.last_message: DNSIncoming | None = None
73
+ self._last_scoped = False
70
74
  self.transport: _WrappedTransport | None = None
71
75
  self.sock_description: str | None = None
72
76
  self._deferred: dict[str, list[DNSIncoming]] = {}
@@ -76,8 +80,10 @@ class AsyncListener:
76
80
  # flood cannot defeat single-slot dedup; relies on dict insertion
77
81
  # order so the oldest entry is evicted first. Only payloads
78
82
  # without a QU question are cached so unicast replies still go
79
- # out on every receipt.
80
- self._recent_packets: dict[bytes, float] = {}
83
+ # out on every receipt. Each entry records whether the copy came
84
+ # from a scoped sender: only that copy scopes link-local AAAA
85
+ # records, so it is not suppressed by an unscoped one.
86
+ self._recent_packets: dict[bytes, tuple[float, bool]] = {}
81
87
  super().__init__()
82
88
 
83
89
  def cancel_pending_timers(self) -> None:
@@ -228,8 +234,12 @@ class AsyncListener:
228
234
  data: _bytes,
229
235
  addrs: tuple[str, int] | tuple[str, int, int, int],
230
236
  ) -> None:
237
+ # Only a response's records take the sender's scope (QR is the top
238
+ # bit of header byte 2).
239
+ scoped = len(addrs) == 4 and addrs[3] != 0 and data_len > 2 and (data[2] & 0x80) != 0
231
240
  if (
232
241
  self.data == data
242
+ and (self._last_scoped or not scoped)
233
243
  and (now - _DUPLICATE_PACKET_SUPPRESSION_INTERVAL) < self.last_time
234
244
  and self.last_message is not None
235
245
  and not self.last_message.has_qu_question()
@@ -246,7 +256,7 @@ class AsyncListener:
246
256
  )
247
257
  return
248
258
 
249
- # `get(data, -1e30)` keeps the suppression compare a single C
259
+ # `get(data, _NOT_SEEN)` keeps the suppression compare a single C
250
260
  # double compare; the sentinel is far below any real `now -
251
261
  # _DUPLICATE_PACKET_SUPPRESSION_INTERVAL` so a cache miss never
252
262
  # triggers the branch even when `now` is small (time.monotonic
@@ -254,8 +264,9 @@ class AsyncListener:
254
264
  # negative for the first interval after boot). Only non-QU payloads
255
265
  # are cached, so any hit here is safe to suppress without re-
256
266
  # checking has_qu_question.
257
- recent_time = self._recent_packets.get(data, -1e30)
258
- if (now - _DUPLICATE_PACKET_SUPPRESSION_INTERVAL) < recent_time:
267
+ recent = self._recent_packets.get(data, _NOT_SEEN)
268
+ recent_time = recent[0]
269
+ if (now - _DUPLICATE_PACKET_SUPPRESSION_INTERVAL) < recent_time and (recent[1] or not scoped):
259
270
  # No timestamp refresh on hit so the suppression window
260
271
  # ends at first-observation + interval; one parse-and-
261
272
  # dispatch fires per payload per interval, capping the
@@ -291,15 +302,16 @@ class AsyncListener:
291
302
  self.data = data
292
303
  self.last_time = now
293
304
  self.last_message = msg
305
+ self._last_scoped = scoped
294
306
  if not msg.has_qu_question():
295
307
  # Refresh LRU position when an entry exists but the
296
308
  # suppression window has expired; otherwise evict the oldest
297
309
  # entry once the window is full.
298
- if data in self._recent_packets:
310
+ if recent is not _NOT_SEEN:
299
311
  del self._recent_packets[data]
300
312
  elif len(self._recent_packets) >= _RECENT_PACKETS_MAX:
301
313
  del self._recent_packets[next(iter(self._recent_packets))]
302
- self._recent_packets[data] = now
314
+ self._recent_packets[data] = (now, scoped)
303
315
  if msg.valid is True:
304
316
  if debug:
305
317
  log.debug(
@@ -121,6 +121,7 @@ cdef class DNSIncoming:
121
121
  name_start="unsigned int",
122
122
  offset="unsigned int",
123
123
  address_rec=DNSAddress,
124
+ address=bytes,
124
125
  pointer_rec=DNSPointer,
125
126
  text_rec=DNSText,
126
127
  srv_rec=DNSService,
@@ -530,14 +530,16 @@ class DNSIncoming:
530
530
  )
531
531
  return hinfo_rec
532
532
  if type_ == _TYPE_AAAA:
533
+ address = self._read_string(16)
533
534
  address_rec = DNSAddress.__new__(DNSAddress)
535
+ # Zone indexes only apply to link-local fe80::/10 (RFC 4007 section 6).
534
536
  address_rec._fast_init(
535
537
  domain,
536
538
  type_,
537
539
  class_,
538
540
  ttl,
539
- self._read_string(16),
540
- self.scope_id,
541
+ address,
542
+ self.scope_id if address[0] == 0xFE and (address[1] & 0xC0) == 0x80 else None,
541
543
  self.now,
542
544
  )
543
545
  return address_rec
@@ -21,6 +21,7 @@ from types import TracebackType # used in type hints
21
21
  from typing import (
22
22
  TYPE_CHECKING,
23
23
  Any,
24
+ Self,
24
25
  cast,
25
26
  )
26
27
 
@@ -762,7 +763,7 @@ class ServiceBrowser(_ServiceBrowserBase, threading.Thread):
762
763
  getattr(self, "native_id", self.ident),
763
764
  )
764
765
 
765
- def __enter__(self) -> ServiceBrowser:
766
+ def __enter__(self) -> Self:
766
767
  return self
767
768
 
768
769
  def __exit__( # pylint: disable=useless-return
@@ -783,9 +783,9 @@ class ServiceInfo(RecordUpdateListener):
783
783
  ip_addr = get_ip_address_object_from_record(record)
784
784
  if ip_addr is None:
785
785
  continue
786
- # The cache keeps scoped and unscoped link-local AAAA
787
- # records as separate entries because DNSAddress equality
788
- # includes scope_id. Collapse them here so each address
786
+ # Records added to the cache directly, not through the
787
+ # record manager, can hold scoped and unscoped copies of a
788
+ # link-local AAAA. Collapse them here so each address
789
789
  # appears once; the scoped variant wins so callers of
790
790
  # parsed_scoped_addresses() get a %<interface_index>-
791
791
  # qualified link-local address when one was observed.
@@ -10,7 +10,6 @@ from __future__ import annotations
10
10
  import asyncio
11
11
  import concurrent.futures
12
12
  import contextlib
13
- import sys
14
13
  from collections.abc import Awaitable, Coroutine
15
14
  from typing import Any
16
15
 
@@ -58,7 +57,7 @@ async def wait_future_or_timeout(future: asyncio.Future[bool | None], timeout: f
58
57
  try:
59
58
  await future
60
59
  except asyncio.CancelledError:
61
- if sys.version_info >= (3, 11) and (task := asyncio.current_task()) and task.cancelling():
60
+ if (task := asyncio.current_task()) and task.cancelling():
62
61
  raise
63
62
  finally:
64
63
  handle.cancel()
@@ -11,6 +11,7 @@ import asyncio
11
11
  import contextlib
12
12
  from collections.abc import Awaitable, Callable
13
13
  from types import TracebackType # used in type hints
14
+ from typing import Self
14
15
 
15
16
  from ._core import Zeroconf
16
17
  from ._dns import DNSQuestionType
@@ -48,7 +49,7 @@ class AsyncServiceBrowser(_ServiceBrowserBase):
48
49
  super().__init__(zeroconf, type_, handlers, listener, addr, port, delay, question_type)
49
50
  self._async_start()
50
51
 
51
- async def __aenter__(self) -> AsyncServiceBrowser:
52
+ async def __aenter__(self) -> Self:
52
53
  return self
53
54
 
54
55
  async def __aexit__(
@@ -138,7 +139,7 @@ class AsyncZeroconf:
138
139
  )
139
140
  self.async_browsers: dict[ServiceListener, AsyncServiceBrowser] = {}
140
141
 
141
- async def __aenter__(self) -> AsyncZeroconf:
142
+ async def __aenter__(self) -> Self:
142
143
  return self
143
144
 
144
145
  async def __aexit__(
@@ -20,7 +20,16 @@ from unittest import mock
20
20
 
21
21
  import ifaddr
22
22
 
23
- from zeroconf import DNSIncoming, DNSOutgoing, DNSQuestion, DNSRecord, ServiceInfo, Zeroconf, const
23
+ from zeroconf import (
24
+ DNSAddress,
25
+ DNSIncoming,
26
+ DNSOutgoing,
27
+ DNSQuestion,
28
+ DNSRecord,
29
+ ServiceInfo,
30
+ Zeroconf,
31
+ const,
32
+ )
24
33
  from zeroconf._history import QuestionHistory
25
34
 
26
35
  _MONOTONIC_RESOLUTION = time.get_clock_info("monotonic").resolution
@@ -116,6 +125,23 @@ def make_service_info(
116
125
  )
117
126
 
118
127
 
128
+ def aaaa_response_packet(name: str, *addresses: str) -> bytes:
129
+ """Build a response packet with one cache-flush AAAA per address, `fe80::1` by default."""
130
+ generated = DNSOutgoing(const._FLAGS_QR_RESPONSE)
131
+ for address in addresses or ("fe80::1",):
132
+ generated.add_answer_at_time(
133
+ DNSAddress(
134
+ name,
135
+ const._TYPE_AAAA,
136
+ const._CLASS_IN | const._CLASS_UNIQUE,
137
+ 120,
138
+ socket.inet_pton(socket.AF_INET6, address),
139
+ ),
140
+ 0,
141
+ )
142
+ return generated.packets()[0]
143
+
144
+
119
145
  def mock_incoming_msg(records: Iterable[DNSRecord]) -> DNSIncoming:
120
146
  """Build a `DNSIncoming` response message from a list of `DNSRecord`s."""
121
147
  generated = DNSOutgoing(const._FLAGS_QR_RESPONSE)
@@ -18,7 +18,7 @@ from zeroconf._services import info as service_info
18
18
  from zeroconf.asyncio import AsyncZeroconf
19
19
 
20
20
  # The dev dependency group only installs hypothesis on CPython (the CI
21
- # matrix pins pypy-3.10 and hypothesis ships no pp310 wheels), and a
21
+ # matrix pins pypy-3.11 and hypothesis ships no pp311 wheels), and a
22
22
  # non-dev install has no hypothesis at all.
23
23
  collect_ignore: list[str] = []
24
24
  if importlib.util.find_spec("hypothesis") is None:
@@ -45,7 +45,7 @@ _BENCHMARKS_DIR = "tests/benchmarks"
45
45
  # `test_async_service_registration*` and `test_async_tasks` entries
46
46
  # share a single root cause: `Zeroconf.async_close()` -> ... ->
47
47
  # `ServiceBrowser.cancel()` calls `Thread.join()` to drain the
48
- # dedicated browser thread, and on Python 3.10-3.12 the thread is
48
+ # dedicated browser thread, and on Python 3.11-3.12 the thread is
49
49
  # still alive when the join happens. `test_use_asyncio_false_*` is
50
50
  # by design (sync bootstrap when `use_asyncio=False` is requested from
51
51
  # inside a running loop); `test_run_coro_with_timeout` exercises the
@@ -35,6 +35,7 @@ from .. import (
35
35
  _restamp_cache,
36
36
  _wait_for,
37
37
  _wait_for_start,
38
+ aaaa_response_packet,
38
39
  has_working_ipv6,
39
40
  make_service_info,
40
41
  mock_incoming_msg,
@@ -1686,3 +1687,40 @@ async def test_close_zeroconf_without_browser_after_start_up_queries(quick_timin
1686
1687
  finally:
1687
1688
  await aio_zeroconf_registrar.async_close()
1688
1689
  await browser.async_cancel()
1690
+
1691
+
1692
+ def test_service_browser_aaaa_scope_variant_does_not_trigger_update(zc: Zeroconf) -> None:
1693
+ """A cached AAAA arriving again under another scope fires no Updated."""
1694
+ type_ = "_hap._tcp.local."
1695
+ registration_name = f"xxxyyy.{type_}"
1696
+ updates: list[str] = []
1697
+ service_added = Event()
1698
+
1699
+ class MyServiceListener(r.ServiceListener):
1700
+ def add_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def]
1701
+ if name == registration_name:
1702
+ service_added.set()
1703
+
1704
+ def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def]
1705
+ pass
1706
+
1707
+ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def]
1708
+ if name == registration_name:
1709
+ updates.append(name)
1710
+
1711
+ browser = r.ServiceBrowser(zc, type_, None, MyServiceListener())
1712
+ try:
1713
+ info = make_service_info(type_, registration_name, addresses=[socket.inet_aton("10.7.4.2")])
1714
+ _inject_response(zc, mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text()]))
1715
+ assert service_added.wait(timeout=5)
1716
+
1717
+ assert info.server is not None
1718
+ packet = aaaa_response_packet(info.server)
1719
+ _inject_response(zc, r.DNSIncoming(packet, ("fe80::2", 5353), 31))
1720
+ assert _wait_for(lambda: len(updates) == 1)
1721
+ _inject_response(zc, r.DNSIncoming(packet, ("::ffff:10.7.4.3", 5353), 0))
1722
+ _inject_response(zc, r.DNSIncoming(packet, ("fe80::2", 5353), 15))
1723
+ time.sleep(0.2)
1724
+ assert updates == [registration_name]
1725
+ finally:
1726
+ browser.cancel()
@@ -28,6 +28,7 @@ from zeroconf.asyncio import AsyncZeroconf
28
28
  from .. import (
29
29
  QUICK_REQUEST_TIMEOUT_MS,
30
30
  _inject_response,
31
+ aaaa_response_packet,
31
32
  has_working_ipv6,
32
33
  make_service_info,
33
34
  mock_incoming_msg,
@@ -3022,3 +3023,21 @@ def test_addresses_setter_rejects_invalid_address():
3022
3023
  info = ServiceInfo(type_, f"xxxyyy.{type_}", 80, server="spare-rig.local.")
3023
3024
  with pytest.raises(TypeError, match="Addresses must either be"):
3024
3025
  info.addresses = ["not an address"]
3026
+
3027
+
3028
+ @pytest.mark.asyncio
3029
+ async def test_aaaa_known_answer_sent_once_per_address() -> None:
3030
+ """An AAAA received under several scopes is sent as one known answer."""
3031
+ aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
3032
+ zc = aiozc.zeroconf
3033
+ packet = aaaa_response_packet("host.local.")
3034
+ for source, scope in ((("fe80::2", 5353), 2), (("::ffff:192.0.2.1", 5353), 0)):
3035
+ zc.record_manager.async_updates_from_response(r.DNSIncoming(packet, source, scope))
3036
+ sent: list[DNSOutgoing] = []
3037
+ info = ServiceInfo("_test._tcp.local.", "x._test._tcp.local.", server="host.local.")
3038
+ with patch.object(zc, "async_send", lambda out, *args, **kwargs: sent.append(out)):
3039
+ await info.async_request(zc, timeout=200)
3040
+ assert sent
3041
+ for out in sent:
3042
+ assert [record.type for record, _ in out.answers].count(const._TYPE_AAAA) == 1
3043
+ await aiozc.async_close()
@@ -23,7 +23,7 @@ from zeroconf._handlers.multicast_outgoing_queue import (
23
23
  from zeroconf._utils.time import millis_to_seconds
24
24
  from zeroconf.asyncio import AsyncZeroconf
25
25
 
26
- from . import _clear_cache, _inject_response, has_working_ipv6, make_service_info
26
+ from . import _clear_cache, _inject_response, aaaa_response_packet, has_working_ipv6, make_service_info
27
27
 
28
28
  log = logging.getLogger("zeroconf")
29
29
  original_logging_level = logging.NOTSET
@@ -1839,3 +1839,68 @@ async def test_async_updates_complete_iteration_safe():
1839
1839
  # This should not raise RuntimeError: set changed size during iteration
1840
1840
  zc.record_manager.async_updates_complete(False)
1841
1841
  await aiozc.async_close()
1842
+
1843
+
1844
+ @pytest.mark.parametrize("scopes", [(0, 2), (2, 0), (None, 2), (2, 15)])
1845
+ @pytest.mark.asyncio
1846
+ async def test_aaaa_cached_once_per_address(scopes: tuple[int | None, int | None]) -> None:
1847
+ """The cache keeps one AAAA per address, preferring a scoped copy and then the first scope."""
1848
+ aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
1849
+ zc = aiozc.zeroconf
1850
+ packet = aaaa_response_packet("host.local.")
1851
+ for scope in scopes:
1852
+ zc.record_manager.async_updates_from_response(r.DNSIncoming(packet, ("fe80::2", 5353), scope))
1853
+ records = zc.cache.async_all_by_details("host.local.", const._TYPE_AAAA, const._CLASS_IN)
1854
+ assert [cast(r.DNSAddress, record).scope_id for record in records] == [2]
1855
+ await aiozc.async_close()
1856
+
1857
+
1858
+ @pytest.mark.asyncio
1859
+ async def test_aaaa_unscoped_copy_refreshes_scoped() -> None:
1860
+ """An unscoped cache-flush copy refreshes the cached scoped AAAA."""
1861
+ aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
1862
+ zc = aiozc.zeroconf
1863
+ packet = aaaa_response_packet("host.local.")
1864
+ now = current_time_millis()
1865
+ zc.record_manager.async_updates_from_response(r.DNSIncoming(packet, ("fe80::2", 5353), 2, now - 2000))
1866
+ zc.record_manager.async_updates_from_response(r.DNSIncoming(packet, ("::ffff:192.0.2.1", 5353), 0, now))
1867
+ (record,) = zc.cache.async_all_by_details("host.local.", const._TYPE_AAAA, const._CLASS_IN)
1868
+ assert cast(r.DNSAddress, record).scope_id == 2
1869
+ assert record.ttl == 120
1870
+ assert record.created == now
1871
+ await aiozc.async_close()
1872
+
1873
+
1874
+ @pytest.mark.parametrize(("cached_scope", "goodbye_scope"), [(2, 0), (0, 2), (2, 15)])
1875
+ @pytest.mark.asyncio
1876
+ async def test_aaaa_goodbye_under_another_scope_removes_cached(cached_scope: int, goodbye_scope: int) -> None:
1877
+ """A goodbye for an address removes and reports its cached AAAA whatever scope either came with."""
1878
+ aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
1879
+ zc = aiozc.zeroconf
1880
+ zc.record_manager.async_updates_from_response(
1881
+ r.DNSIncoming(aaaa_response_packet("host.local."), ("fe80::2", 5353), cached_scope)
1882
+ )
1883
+ updates: list[r.RecordUpdate] = []
1884
+
1885
+ class EventStore(r.RecordUpdateListener):
1886
+ def async_update_records(self, zc: Zeroconf, now: float, records: list[r.RecordUpdate]) -> None:
1887
+ updates.extend(records)
1888
+
1889
+ zc.record_manager.async_add_listener(EventStore(), None)
1890
+ goodbye = r.DNSOutgoing(const._FLAGS_QR_RESPONSE)
1891
+ goodbye.add_answer_at_time(
1892
+ r.DNSAddress(
1893
+ "host.local.",
1894
+ const._TYPE_AAAA,
1895
+ const._CLASS_IN | const._CLASS_UNIQUE,
1896
+ 0,
1897
+ socket.inet_pton(socket.AF_INET6, "fe80::1"),
1898
+ ),
1899
+ 0,
1900
+ )
1901
+ zc.record_manager.async_updates_from_response(
1902
+ r.DNSIncoming(goodbye.packets()[0], ("fe80::2", 5353), goodbye_scope)
1903
+ )
1904
+ assert zc.cache.async_all_by_details("host.local.", const._TYPE_AAAA, const._CLASS_IN) == []
1905
+ assert [(update.new.ttl, update.old is not None) for update in updates] == [(0, True)]
1906
+ await aiozc.async_close()
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import logging
6
6
  import unittest
7
7
  import unittest.mock
8
+ from typing import cast
8
9
  from unittest.mock import MagicMock, patch
9
10
 
10
11
  import zeroconf as r
@@ -19,7 +20,7 @@ from zeroconf import (
19
20
  from zeroconf._protocol import outgoing
20
21
  from zeroconf._protocol.incoming import DNSIncoming
21
22
 
22
- from . import QuestionHistoryWithoutSuppression
23
+ from . import QuestionHistoryWithoutSuppression, aaaa_response_packet
23
24
 
24
25
  log = logging.getLogger("zeroconf")
25
26
  original_logging_level = logging.NOTSET
@@ -275,6 +276,64 @@ def test_guard_against_duplicate_packets(zc: Zeroconf) -> None:
275
276
  _handle_query_or_defer.reset_mock()
276
277
 
277
278
 
279
+ def test_guard_against_duplicate_packets_per_sender_scope(zc: Zeroconf) -> None:
280
+ """A scoped copy of a response follows an unscoped one, and suppresses later copies from any sender."""
281
+ listener = _listener.AsyncListener(zc)
282
+ listener.transport = MagicMock()
283
+ packet = aaaa_response_packet("peer.local.")
284
+ other_packet = aaaa_response_packet("other.local.")
285
+ v4_mapped = ("::ffff:192.0.2.1", 5353, 0, 0)
286
+ v6_link_local = ("fe80::2", 5353, 0, 2)
287
+
288
+ now = current_time_millis()
289
+ listener._process_datagram_at_time(False, len(packet), now, packet, v4_mapped)
290
+ listener._process_datagram_at_time(False, len(packet), now, packet, v6_link_local)
291
+ (record,) = zc.cache.async_all_by_details("peer.local.", const._TYPE_AAAA, const._CLASS_IN)
292
+ assert cast(r.DNSAddress, record).scope_id == 2
293
+
294
+ listener._process_datagram_at_time(False, len(other_packet), now, other_packet, v4_mapped)
295
+ processed = listener.last_message
296
+ for addrs in (v4_mapped, v6_link_local, ("fe80::2", 5353, 0, 15)):
297
+ listener._process_datagram_at_time(False, len(packet), now, packet, addrs)
298
+ assert listener.last_message is processed
299
+
300
+
301
+ def test_guard_against_duplicate_queries_across_sender_scopes(zc: Zeroconf) -> None:
302
+ """The scoped copy of a query is still suppressed as a duplicate of the unscoped one."""
303
+ zc.registry.async_add(
304
+ ServiceInfo(
305
+ "_http._tcp.local.",
306
+ "Test._http._tcp.local.",
307
+ server="Test._http._tcp.local.",
308
+ port=4,
309
+ )
310
+ )
311
+ handled: list[tuple[str, int]] = []
312
+
313
+ class SubListener(_listener.AsyncListener):
314
+ def handle_query_or_defer(
315
+ self,
316
+ msg: DNSIncoming,
317
+ addr: str,
318
+ port: int,
319
+ transport: _engine._WrappedTransport,
320
+ v6_flow_scope: tuple[()] | tuple[int, int] = (),
321
+ ) -> None:
322
+ handled.append((addr, port))
323
+
324
+ listener = SubListener(zc)
325
+ listener.transport = MagicMock()
326
+
327
+ query = r.DNSOutgoing(const._FLAGS_QR_QUERY)
328
+ query.add_question(r.DNSQuestion("peer.local.", const._TYPE_AAAA, const._CLASS_IN))
329
+ packet = query.packets()[0]
330
+
331
+ now = current_time_millis()
332
+ listener._process_datagram_at_time(False, len(packet), now, packet, ("::ffff:192.0.2.1", 5353, 0, 0))
333
+ listener._process_datagram_at_time(False, len(packet), now, packet, ("fe80::2", 5353, 0, 2))
334
+ assert handled == [("::ffff:192.0.2.1", 5353)]
335
+
336
+
278
337
  def test_guard_against_alternating_duplicate_packets(zc: Zeroconf) -> None:
279
338
  """Alternating two distinct payloads must not bypass duplicate suppression."""
280
339
  zc.registry.async_add(
@@ -16,7 +16,7 @@ from zeroconf import DNSHinfo, DNSIncoming, DNSText, const, current_time_millis
16
16
  from zeroconf._logger import _MAX_SEEN_LOGS
17
17
  from zeroconf._protocol import incoming as _incoming_module
18
18
 
19
- from . import add_question_batch, has_working_ipv6
19
+ from . import aaaa_response_packet, add_question_batch, has_working_ipv6
20
20
 
21
21
  log = logging.getLogger("zeroconf")
22
22
  original_logging_level = logging.NOTSET
@@ -1497,3 +1497,10 @@ def test_hinfo_second_character_string_missing_skips_record() -> None:
1497
1497
  parsed = r.DNSIncoming(packet)
1498
1498
  assert parsed.valid is True
1499
1499
  assert parsed.answers() == []
1500
+
1501
+
1502
+ def test_aaaa_scope_only_applied_to_link_local() -> None:
1503
+ """Only link-local AAAA records take the receiving scope."""
1504
+ packet = aaaa_response_packet("host.local.", "fe80::1", "2001:db8::1")
1505
+ answers = cast("list[r.DNSAddress]", r.DNSIncoming(packet, ("fe80::2", 5353), 2).answers())
1506
+ assert [answer.scope_id for answer in answers] == [2, None]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes