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.
- {zeroconf-0.152.0 → zeroconf-0.153.0}/CHANGELOG.md +17 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/PKG-INFO +4 -5
- {zeroconf-0.152.0 → zeroconf-0.153.0}/README.rst +2 -2
- {zeroconf-0.152.0 → zeroconf-0.153.0}/pyproject.toml +7 -7
- {zeroconf-0.152.0 → zeroconf-0.153.0}/setup.py +3 -3
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/__init__.py +1 -1
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_cache.pxd +3 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_cache.py +15 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_core.py +2 -1
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_dns.pxd +2 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_dns.py +5 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_handlers/record_manager.pxd +5 -1
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_handlers/record_manager.py +18 -2
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_listener.pxd +3 -1
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_listener.py +19 -7
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_protocol/incoming.pxd +1 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_protocol/incoming.py +4 -2
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_services/browser.py +2 -1
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_services/info.py +3 -3
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_utils/asyncio.py +1 -2
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/asyncio.py +3 -2
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/__init__.py +27 -1
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/conftest.py +2 -2
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/services/test_browser.py +38 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/services/test_info.py +19 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_handlers.py +66 -1
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_listener.py +60 -1
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_protocol.py +8 -1
- {zeroconf-0.152.0 → zeroconf-0.153.0}/COPYING +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/build_ext.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/docs/Makefile +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/docs/_ext/zeroconfautodocfix.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/docs/api.rst +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/docs/conf.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/docs/index.rst +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_engine.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_exceptions.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_handlers/__init__.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_handlers/answers.pxd +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_handlers/answers.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_handlers/multicast_outgoing_queue.pxd +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_handlers/multicast_outgoing_queue.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_handlers/query_handler.pxd +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_handlers/query_handler.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_history.pxd +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_history.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_logger.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_protocol/__init__.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_protocol/outgoing.pxd +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_protocol/outgoing.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_record_update.pxd +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_record_update.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_services/__init__.pxd +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_services/__init__.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_services/browser.pxd +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_services/info.pxd +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_services/registry.pxd +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_services/registry.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_services/types.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_transport.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_updates.pxd +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_updates.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_utils/__init__.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_utils/ipaddress.pxd +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_utils/ipaddress.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_utils/name.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_utils/net.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_utils/time.pxd +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/_utils/time.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/const.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/src/zeroconf/py.typed +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/__init__.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/helpers.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_cache.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_cache_bound.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_cache_mark_expire.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_incoming.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_ipaddress.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_listener_dedup.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_mark_seen.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_outgoing.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_registry.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_send.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/benchmarks/test_txt_properties.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/services/__init__.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/services/test_registry.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/services/test_types.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_asyncio.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_cache.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_circular_imports.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_core.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_dns.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_engine.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_exceptions.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_fuzz_incoming.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_history.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_init.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_interface_update.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_logger.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_services.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/test_updates.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/utils/__init__.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/utils/test_asyncio.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/utils/test_ipaddress.py +0 -0
- {zeroconf-0.152.0 → zeroconf-0.153.0}/tests/utils/test_name.py +0 -0
- {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.
|
|
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
|
+
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.
|
|
78
|
-
* PyPy 3.
|
|
76
|
+
* CPython 3.11+
|
|
77
|
+
* PyPy 3.11+
|
|
79
78
|
|
|
80
79
|
Versioning
|
|
81
80
|
----------
|
|
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "zeroconf"
|
|
7
|
-
version = "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.
|
|
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.
|
|
83
|
+
python = "^3.11"
|
|
84
84
|
ifaddr = ">=0.1.7"
|
|
85
85
|
|
|
86
86
|
[tool.poetry.group.dev.dependencies]
|
|
87
|
-
pytest = ">=9.
|
|
87
|
+
pytest = ">=9.2.0,<10.0"
|
|
88
88
|
pytest-cov = ">=4,<8"
|
|
89
|
-
pytest-asyncio = ">=1.4.
|
|
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 = "
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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) ->
|
|
216
|
+
def __enter__(self) -> Self:
|
|
216
217
|
return self
|
|
217
218
|
|
|
218
219
|
def __exit__( # pylint: disable=useless-return
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
258
|
-
|
|
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
|
|
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(
|
|
@@ -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
|
-
|
|
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) ->
|
|
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
|
-
#
|
|
787
|
-
#
|
|
788
|
-
#
|
|
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
|
|
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) ->
|
|
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) ->
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|