zeroconf 0.147.2__tar.gz → 0.147.4__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 (98) hide show
  1. {zeroconf-0.147.2 → zeroconf-0.147.4}/CHANGELOG.md +67 -0
  2. {zeroconf-0.147.2 → zeroconf-0.147.4}/PKG-INFO +6 -5
  3. {zeroconf-0.147.2 → zeroconf-0.147.4}/pyproject.toml +34 -31
  4. {zeroconf-0.147.2 → zeroconf-0.147.4}/setup.py +2 -2
  5. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/__init__.py +1 -1
  6. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_core.py +10 -1
  7. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_services/browser.py +11 -1
  8. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/__init__.py +1 -4
  9. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/conftest.py +19 -0
  10. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/services/test_browser.py +5 -10
  11. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/services/test_info.py +4 -4
  12. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/test_asyncio.py +13 -10
  13. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/test_core.py +37 -3
  14. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/test_dns.py +2 -2
  15. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/test_exceptions.py +1 -1
  16. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/utils/test_asyncio.py +9 -3
  17. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/utils/test_net.py +1 -1
  18. {zeroconf-0.147.2 → zeroconf-0.147.4}/COPYING +0 -0
  19. {zeroconf-0.147.2 → zeroconf-0.147.4}/README.rst +0 -0
  20. {zeroconf-0.147.2 → zeroconf-0.147.4}/build_ext.py +0 -0
  21. {zeroconf-0.147.2 → zeroconf-0.147.4}/docs/Makefile +0 -0
  22. {zeroconf-0.147.2 → zeroconf-0.147.4}/docs/_ext/zeroconfautodocfix.py +0 -0
  23. {zeroconf-0.147.2 → zeroconf-0.147.4}/docs/api.rst +0 -0
  24. {zeroconf-0.147.2 → zeroconf-0.147.4}/docs/conf.py +0 -0
  25. {zeroconf-0.147.2 → zeroconf-0.147.4}/docs/index.rst +0 -0
  26. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_cache.pxd +0 -0
  27. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_cache.py +0 -0
  28. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_dns.pxd +0 -0
  29. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_dns.py +0 -0
  30. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_engine.py +0 -0
  31. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_exceptions.py +0 -0
  32. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_handlers/__init__.py +0 -0
  33. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_handlers/answers.pxd +0 -0
  34. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_handlers/answers.py +0 -0
  35. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_handlers/multicast_outgoing_queue.pxd +0 -0
  36. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_handlers/multicast_outgoing_queue.py +0 -0
  37. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_handlers/query_handler.pxd +0 -0
  38. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_handlers/query_handler.py +0 -0
  39. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_handlers/record_manager.pxd +0 -0
  40. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_handlers/record_manager.py +0 -0
  41. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_history.pxd +0 -0
  42. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_history.py +0 -0
  43. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_listener.pxd +0 -0
  44. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_listener.py +0 -0
  45. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_logger.py +0 -0
  46. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_protocol/__init__.py +0 -0
  47. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_protocol/incoming.pxd +0 -0
  48. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_protocol/incoming.py +0 -0
  49. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_protocol/outgoing.pxd +0 -0
  50. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_protocol/outgoing.py +0 -0
  51. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_record_update.pxd +0 -0
  52. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_record_update.py +0 -0
  53. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_services/__init__.pxd +0 -0
  54. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_services/__init__.py +0 -0
  55. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_services/browser.pxd +0 -0
  56. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_services/info.pxd +0 -0
  57. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_services/info.py +0 -0
  58. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_services/registry.pxd +0 -0
  59. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_services/registry.py +0 -0
  60. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_services/types.py +0 -0
  61. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_transport.py +0 -0
  62. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_updates.pxd +0 -0
  63. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_updates.py +0 -0
  64. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_utils/__init__.py +0 -0
  65. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_utils/asyncio.py +0 -0
  66. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_utils/ipaddress.pxd +0 -0
  67. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_utils/ipaddress.py +0 -0
  68. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_utils/name.py +0 -0
  69. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_utils/net.py +0 -0
  70. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_utils/time.pxd +0 -0
  71. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/_utils/time.py +0 -0
  72. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/asyncio.py +0 -0
  73. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/const.py +0 -0
  74. {zeroconf-0.147.2 → zeroconf-0.147.4}/src/zeroconf/py.typed +0 -0
  75. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/benchmarks/__init__.py +0 -0
  76. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/benchmarks/helpers.py +0 -0
  77. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/benchmarks/test_cache.py +0 -0
  78. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/benchmarks/test_incoming.py +0 -0
  79. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/benchmarks/test_outgoing.py +0 -0
  80. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/benchmarks/test_send.py +0 -0
  81. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/benchmarks/test_txt_properties.py +0 -0
  82. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/services/__init__.py +0 -0
  83. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/services/test_registry.py +0 -0
  84. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/services/test_types.py +0 -0
  85. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/test_cache.py +0 -0
  86. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/test_circular_imports.py +0 -0
  87. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/test_engine.py +0 -0
  88. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/test_handlers.py +0 -0
  89. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/test_history.py +0 -0
  90. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/test_init.py +0 -0
  91. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/test_listener.py +0 -0
  92. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/test_logger.py +0 -0
  93. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/test_protocol.py +0 -0
  94. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/test_services.py +0 -0
  95. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/test_updates.py +0 -0
  96. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/utils/__init__.py +0 -0
  97. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/utils/test_ipaddress.py +0 -0
  98. {zeroconf-0.147.2 → zeroconf-0.147.4}/tests/utils/test_name.py +0 -0
@@ -1,5 +1,70 @@
1
1
  # CHANGELOG
2
2
 
3
+ <!-- version list -->
4
+
5
+ ## v0.147.4 (2026-05-16)
6
+
7
+ ### Bug Fixes
8
+
9
+ - **core**: Close owned event loop on Zeroconf.close() to stop FD leak
10
+ ([#1685](https://github.com/python-zeroconf/python-zeroconf/pull/1685),
11
+ [`2f78370`](https://github.com/python-zeroconf/python-zeroconf/commit/2f78370c75d1082afe3191b7447aebfff1206657))
12
+
13
+ ### Build System
14
+
15
+ - Adjust actions checkout ref parameter on release
16
+ ([#1669](https://github.com/python-zeroconf/python-zeroconf/pull/1669),
17
+ [`bc8ec8d`](https://github.com/python-zeroconf/python-zeroconf/commit/bc8ec8d59d875522f75901644d423d30d803a030))
18
+
19
+ ### Documentation
20
+
21
+ - Add CLAUDE.md orientation file and pr-workflow skill
22
+ ([#1672](https://github.com/python-zeroconf/python-zeroconf/pull/1672),
23
+ [`8f8b4d6`](https://github.com/python-zeroconf/python-zeroconf/commit/8f8b4d6526729906337f4562c7b391745bb878af))
24
+
25
+ - Add Cython gotchas section to CLAUDE.md
26
+ ([#1679](https://github.com/python-zeroconf/python-zeroconf/pull/1679),
27
+ [`5cfb09d`](https://github.com/python-zeroconf/python-zeroconf/commit/5cfb09d89395cb507d436c45fc38edb9e44b94c8))
28
+
29
+ - Add SECURITY.md with private vulnerability reporting policy
30
+ ([#1675](https://github.com/python-zeroconf/python-zeroconf/pull/1675),
31
+ [`13f9048`](https://github.com/python-zeroconf/python-zeroconf/commit/13f9048f0f9786ce18e89daef04073847735a006))
32
+
33
+ ### Testing
34
+
35
+ - Add quick_timing fixture and apply to register-heavy tests
36
+ ([#1678](https://github.com/python-zeroconf/python-zeroconf/pull/1678),
37
+ [`d5e1f01`](https://github.com/python-zeroconf/python-zeroconf/commit/d5e1f01bb336ea19d982ce7d99f191723d3f18af))
38
+
39
+ - Fix flaky test_run_coro_with_timeout
40
+ ([#1683](https://github.com/python-zeroconf/python-zeroconf/pull/1683),
41
+ [`277f80d`](https://github.com/python-zeroconf/python-zeroconf/commit/277f80da2c0fea5b256f981bd3f425906f6b7be6))
42
+
43
+ - Pass timeout=0 explicitly in test_event_loop_blocked
44
+ ([#1676](https://github.com/python-zeroconf/python-zeroconf/pull/1676),
45
+ [`1b31ed5`](https://github.com/python-zeroconf/python-zeroconf/commit/1b31ed5fed9db1608255799701cd6f32b494952f))
46
+
47
+ - Pass timeout=200 to ServiceInfo-request timeout tests
48
+ ([#1677](https://github.com/python-zeroconf/python-zeroconf/pull/1677),
49
+ [`01ef6ff`](https://github.com/python-zeroconf/python-zeroconf/commit/01ef6ffd9ff442b3cfb37d2793e0ca6ad5148832))
50
+
51
+
52
+ ## v0.148.0 (2025-10-05)
53
+
54
+ ### Features
55
+
56
+ - Trigger semantic releases for 0.x branch
57
+ ([#1626](https://github.com/python-zeroconf/python-zeroconf/pull/1626),
58
+ [`812a2b3`](https://github.com/python-zeroconf/python-zeroconf/commit/812a2b3ff4370593a7a0c3ad67389c76c434aa9b))
59
+
60
+
61
+ ## v0.147.3 (2025-10-04)
62
+
63
+ ### Bug Fixes
64
+
65
+ - Update poetry to v2 ([#1623](https://github.com/python-zeroconf/python-zeroconf/pull/1623),
66
+ [`2c3c296`](https://github.com/python-zeroconf/python-zeroconf/commit/2c3c29655bd365213a7e0a4360b8dd860d833470))
67
+
3
68
 
4
69
  ## v0.147.2 (2025-09-05)
5
70
 
@@ -1976,3 +2041,5 @@ Include documentation and test files in source distributions, in order to make t
1976
2041
 
1977
2042
 
1978
2043
  ## v0.15.1 (2014-07-10)
2044
+
2045
+ - Initial Release
@@ -1,14 +1,14 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: zeroconf
3
- Version: 0.147.2
3
+ Version: 0.147.4
4
4
  Summary: A pure python implementation of multicast DNS service discovery
5
- License: LGPL-2.1-or-later
5
+ License-Expression: LGPL-2.1-or-later
6
+ License-File: COPYING
6
7
  Author: Paul Scott-Murphy
7
- Requires-Python: >=3.9,<4.0
8
+ Requires-Python: >=3.9
8
9
  Classifier: Development Status :: 5 - Production/Stable
9
10
  Classifier: Intended Audience :: Developers
10
11
  Classifier: Intended Audience :: System Administrators
11
- Classifier: License :: OSI Approved
12
12
  Classifier: Operating System :: MacOS :: MacOS X
13
13
  Classifier: Operating System :: POSIX
14
14
  Classifier: Operating System :: POSIX :: Linux
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.10
18
18
  Classifier: Programming Language :: Python :: 3.11
19
19
  Classifier: Programming Language :: Python :: 3.12
20
20
  Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
21
22
  Classifier: Programming Language :: Python :: Implementation :: CPython
22
23
  Classifier: Programming Language :: Python :: Implementation :: PyPy
23
24
  Classifier: Topic :: Software Development :: Libraries
@@ -1,12 +1,28 @@
1
- [tool.poetry]
1
+ [build-system]
2
+ requires = ['setuptools>=77.0', 'Cython>=3.0.8', "poetry-core>=2.1.0"]
3
+ build-backend = "poetry.core.masonry.api"
4
+
5
+ [project]
2
6
  name = "zeroconf"
3
- version = "0.147.2"
4
- description = "A pure python implementation of multicast DNS service discovery"
5
- authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"]
7
+ version = "0.147.4"
6
8
  license = "LGPL-2.1-or-later"
9
+ description = "A pure python implementation of multicast DNS service discovery"
7
10
  readme = "README.rst"
8
- repository = "https://github.com/python-zeroconf/python-zeroconf"
9
- documentation = "https://python-zeroconf.readthedocs.io"
11
+ authors = [
12
+ { name = "Paul Scott-Murphy" },
13
+ { name = "William McBrine" },
14
+ { name = "Jakub Stasiak" },
15
+ { name = "J. Nick Koston" },
16
+ ]
17
+ requires-python = ">=3.9"
18
+
19
+ [project.urls]
20
+ "Repository" = "https://github.com/python-zeroconf/python-zeroconf"
21
+ "Documentation" = "https://python-zeroconf.readthedocs.io"
22
+ "Bug Tracker" = "https://github.com/python-zeroconf/python-zeroconf/issues"
23
+ "Changelog" = "https://github.com/python-zeroconf/python-zeroconf/blob/master/CHANGELOG.md"
24
+
25
+ [tool.poetry]
10
26
  classifiers=[
11
27
  'Development Status :: 5 - Production/Stable',
12
28
  'Intended Audience :: Developers',
@@ -15,11 +31,6 @@ classifiers=[
15
31
  'Operating System :: POSIX :: Linux',
16
32
  'Operating System :: MacOS :: MacOS X',
17
33
  'Topic :: Software Development :: Libraries',
18
- 'Programming Language :: Python :: 3.9',
19
- 'Programming Language :: Python :: 3.10',
20
- 'Programming Language :: Python :: 3.11',
21
- 'Programming Language :: Python :: 3.12',
22
- 'Programming Language :: Python :: 3.13',
23
34
  'Programming Language :: Python :: Implementation :: CPython',
24
35
  'Programming Language :: Python :: Implementation :: PyPy',
25
36
  ]
@@ -35,22 +46,19 @@ include = [
35
46
  # Make sure we don't package temporary C files generated by the build process
36
47
  exclude = [ "**/*.c" ]
37
48
 
38
- [tool.poetry.urls]
39
- "Bug Tracker" = "https://github.com/python-zeroconf/python-zeroconf/issues"
40
- "Changelog" = "https://github.com/python-zeroconf/python-zeroconf/blob/master/CHANGELOG.md"
41
-
42
49
  [tool.poetry.build]
43
50
  generate-setup-file = true
44
51
  script = "build_ext.py"
45
52
 
46
53
  [tool.semantic_release]
47
54
  branch = "master"
48
- version_toml = ["pyproject.toml:tool.poetry.version"]
55
+ version_toml = ["pyproject.toml:project.version"]
49
56
  version_variables = [
50
57
  "src/zeroconf/__init__.py:__version__"
51
58
  ]
52
59
  build_command = "pip install poetry && poetry build"
53
60
  tag_format = "{version}"
61
+ allow_zero_version = true
54
62
 
55
63
  [tool.semantic_release.changelog]
56
64
  exclude_commit_patterns = [
@@ -64,8 +72,11 @@ keep_trailing_newline = true
64
72
  [tool.semantic_release.branches.master]
65
73
  match = "master"
66
74
 
75
+ [tool.semantic_release.branches."release-0.x"]
76
+ match = "release-0.x"
77
+
67
78
  [tool.semantic_release.branches.noop]
68
- match = "(?!master$)"
79
+ match = "(?!(master|release-0.x)$)"
69
80
  prerelease = true
70
81
 
71
82
  [tool.poetry.dependencies]
@@ -74,16 +85,16 @@ ifaddr = ">=0.1.7"
74
85
 
75
86
  [tool.poetry.group.dev.dependencies]
76
87
  pytest = ">=7.2,<9.0"
77
- pytest-cov = ">=4,<7"
78
- pytest-asyncio = ">=0.20.3,<1.2.0"
79
- cython = "^3.0.5"
80
- setuptools = ">=65.6.3,<81.0.0"
88
+ pytest-cov = ">=4,<8"
89
+ pytest-asyncio = ">=0.20.3,<1.3.0"
90
+ cython = "^3.2.4"
91
+ setuptools = ">=65.6.3,<83.0.0"
81
92
  pytest-timeout = "^2.1.0"
82
- pytest-codspeed = ">=3.1,<5.0"
93
+ pytest-codspeed = ">=4.5.0,<5.0"
83
94
 
84
95
  [tool.poetry.group.docs.dependencies]
85
96
  sphinx = "^7.4.7 || ^8.1.3"
86
- sphinx-rtd-theme = "^3.0.2"
97
+ sphinx-rtd-theme = "^3.1.0"
87
98
 
88
99
  [tool.ruff]
89
100
  target-version = "py39"
@@ -159,12 +170,9 @@ select = [
159
170
  "FLY002", # too many to fix right now
160
171
  "PT018", # too many to fix right now
161
172
  "PLR0124", # too many to fix right now
162
- "SIM202" , # too many to fix right now
163
173
  "PT012" , # too many to fix right now
164
174
  "TID252", # too many to fix right now
165
175
  "PLR0913", # skip this one
166
- "SIM102" , # too many to fix right now
167
- "SIM108", # too many to fix right now
168
176
  "T201", # too many to fix right now
169
177
  "PT004", # nice to have
170
178
  ]
@@ -266,11 +274,6 @@ allow_untyped_defs = true
266
274
  module = "bench.*"
267
275
  ignore_errors = true
268
276
 
269
- [build-system]
270
- # 1.5.2 required for https://github.com/python-poetry/poetry/issues/7505
271
- requires = ['setuptools>=65.4.1', 'wheel', 'Cython>=3.0.8', "poetry-core>=1.5.2"]
272
- build-backend = "poetry.core.masonry.api"
273
-
274
277
  [tool.codespell]
275
278
  ignore-words-list = ["additionals", "HASS"]
276
279
 
@@ -19,7 +19,7 @@ install_requires = \
19
19
 
20
20
  setup_kwargs = {
21
21
  'name': 'zeroconf',
22
- 'version': '0.147.2',
22
+ 'version': '0.147.4',
23
23
  'description': 'A pure python implementation of multicast DNS service discovery',
24
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 is fork of pyzeroconf, Multicast DNS Service Discovery for Python,\noriginally by Paul Scott-Murphy (https://github.com/paulsm/pyzeroconf),\nmodified by William McBrine (https://github.com/wmcbrine/pyzeroconf).\n\nThe original William McBrine\'s fork note::\n\n This fork is used in all of my TiVo-related projects: HME for Python\n (and therefore HME/VLC), Network Remote, Remote Proxy, and pyTivo.\n Before this, I was tracking the changes for zeroconf.py in three\n separate repos. I figured I should have an authoritative source.\n\n Although I make changes based on my experience with TiVos, I expect that\n they\'re generally applicable. This version also includes patches found\n on the now-defunct (?) Launchpad repo of pyzeroconf, and elsewhere\n around the net -- not always well-documented, sorry.\n\nCompatible with:\n\n* Bonjour\n* Avahi\n\nCompared to some other Zeroconf/Bonjour/Avahi Python packages, python-zeroconf:\n\n* isn\'t tied to Bonjour or Avahi\n* doesn\'t use D-Bus\n* doesn\'t force you to use particular event loop or Twisted (asyncio is used under the hood but not required)\n* is pip-installable\n* has PyPI distribution\n* has an optional cython extension for performance (pure python is supported as well)\n\nPython compatibility\n--------------------\n\n* CPython 3.9+\n* PyPy 3.9+\n\nVersioning\n----------\n\nThis project uses semantic versioning.\n\nStatus\n------\n\nThis project is actively maintained.\n\nTraffic Reduction\n-----------------\n\nBefore version 0.32, most traffic reduction techniques described in https://datatracker.ietf.org/doc/html/rfc6762#section-7\nwhere not implemented which could lead to excessive network traffic. It is highly recommended that version 0.32 or later\nis used if this is a concern.\n\nIPv6 support\n------------\n\nIPv6 support is relatively new and currently limited, specifically:\n\n* `InterfaceChoice.All` is an alias for `InterfaceChoice.Default` on non-POSIX\n systems.\n* Dual-stack IPv6 sockets are used, which may not be supported everywhere (some\n BSD variants do not have them).\n* Listening on localhost (`::1`) does not work. Help with understanding why is\n appreciated.\n\nHow to get python-zeroconf?\n===========================\n\n* PyPI page https://pypi.org/project/zeroconf/\n* GitHub project https://github.com/python-zeroconf/python-zeroconf\n\nThe easiest way to install python-zeroconf is using pip::\n\n pip install zeroconf\n\n\n\nHow do I use it?\n================\n\nHere\'s an example of browsing for a service:\n\n.. code-block:: python\n\n from zeroconf import ServiceBrowser, ServiceListener, Zeroconf\n\n\n class MyListener(ServiceListener):\n\n def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:\n print(f"Service {name} updated")\n\n def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None:\n print(f"Service {name} removed")\n\n def add_service(self, zc: Zeroconf, type_: str, name: str) -> None:\n info = zc.get_service_info(type_, name)\n print(f"Service {name} added, service info: {info}")\n\n\n zeroconf = Zeroconf()\n listener = MyListener()\n browser = ServiceBrowser(zeroconf, "_http._tcp.local.", listener)\n try:\n input("Press enter to exit...\\n\\n")\n finally:\n zeroconf.close()\n\n.. note::\n\n Discovery and service registration use *all* available network interfaces by default.\n If you want to customize that you need to specify ``interfaces`` argument when\n constructing ``Zeroconf`` object (see the code for details).\n\nIf you don\'t know the name of the service you need to browse for, try:\n\n.. code-block:: python\n\n from zeroconf import ZeroconfServiceTypes\n print(\'\\n\'.join(ZeroconfServiceTypes.find()))\n\nSee examples directory for more.\n\nChangelog\n=========\n\n`Changelog <CHANGELOG.md>`_\n\nLicense\n=======\n\nLGPL, see COPYING file for details.\n',
25
25
  'author': 'Paul Scott-Murphy',
@@ -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.9,<4.0',
34
+ 'python_requires': '>=3.9',
35
35
  }
36
36
  from build_ext import *
37
37
  build(setup_kwargs)
@@ -88,7 +88,7 @@ from ._utils.time import ( # noqa # import needed for backwards compat
88
88
 
89
89
  __author__ = "Paul Scott-Murphy, William McBrine"
90
90
  __maintainer__ = "Jakub Stasiak <jakub@stasiak.at>"
91
- __version__ = "0.147.2"
91
+ __version__ = "0.147.4"
92
92
  __license__ = "LGPL"
93
93
 
94
94
 
@@ -676,13 +676,22 @@ class Zeroconf(QuietLogger):
676
676
 
677
677
  def _shutdown_threads(self) -> None:
678
678
  """Shutdown any threads."""
679
+ assert self.loop is not None
680
+ if self.loop.is_closed():
681
+ # close() is documented as idempotent — a second call after the
682
+ # loop has been torn down must be a no-op rather than raising.
683
+ return
679
684
  self.notify_all()
680
685
  if not self._loop_thread:
681
686
  return
682
- assert self.loop is not None
683
687
  shutdown_loop(self.loop)
684
688
  self._loop_thread.join()
685
689
  self._loop_thread = None
690
+ # The loop's selector (epoll FD on Linux) and self-pipe sockets stay
691
+ # open until loop.close() is called. We own this loop because
692
+ # _start_thread() created it, so close it here to avoid leaking
693
+ # those file descriptors across Zeroconf() construct/close cycles.
694
+ self.loop.close()
686
695
 
687
696
  def close(self) -> None:
688
697
  """Ends the background threads, and prevent this instance from
@@ -23,6 +23,7 @@ USA
23
23
  from __future__ import annotations
24
24
 
25
25
  import asyncio
26
+ import contextlib
26
27
  import heapq
27
28
  import queue
28
29
  import random
@@ -793,7 +794,16 @@ class ServiceBrowser(_ServiceBrowserBase, threading.Thread):
793
794
  """Cancel the browser."""
794
795
  assert self.zc.loop is not None
795
796
  self.queue.put(None)
796
- self.zc.loop.call_soon_threadsafe(self._async_cancel)
797
+ # While the loop is running, _async_cancel stops the query scheduler
798
+ # and cancels the query-sender task — that is the normal cleanup
799
+ # path. Skip scheduling solely because the loop is closed: a closed
800
+ # loop rejects call_soon_threadsafe with RuntimeError. The
801
+ # is_closed() check narrows the common case (loop already closed by
802
+ # Zeroconf.close()) without paying for raise/catch; suppress covers
803
+ # the residual is_closed() -> call_soon_threadsafe race window.
804
+ with contextlib.suppress(RuntimeError):
805
+ if not self.zc.loop.is_closed():
806
+ self.zc.loop.call_soon_threadsafe(self._async_cancel)
797
807
  self.join()
798
808
 
799
809
  def run(self) -> None:
@@ -95,10 +95,7 @@ def time_changed_millis(millis: float | None = None) -> None:
95
95
  """Call all scheduled events for a time."""
96
96
  loop = asyncio.get_running_loop()
97
97
  loop_time = loop.time()
98
- if millis is not None:
99
- mock_seconds_into_future = millis / 1000
100
- else:
101
- mock_seconds_into_future = loop_time
98
+ mock_seconds_into_future = millis / 1000 if millis is not None else loop_time
102
99
 
103
100
  with mock.patch("time.monotonic", return_value=mock_seconds_into_future):
104
101
  for task in list(loop._scheduled): # type: ignore[attr-defined]
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import threading
6
+ from collections.abc import Generator
6
7
  from unittest.mock import patch
7
8
 
8
9
  import pytest
@@ -40,3 +41,21 @@ def disable_duplicate_packet_suppression():
40
41
  """
41
42
  with patch.object(const, "_DUPLICATE_PACKET_SUPPRESSION_INTERVAL", 0):
42
43
  yield
44
+
45
+
46
+ @pytest.fixture
47
+ def quick_timing() -> Generator[None]:
48
+ """Shorten the probe/announce/goodbye intervals for tests on loopback.
49
+
50
+ The production values (_CHECK_TIME=500ms, _REGISTER_TIME=225ms,
51
+ _UNREGISTER_TIME=125ms) exist for RFC 6762 interop on real
52
+ networks. Tests on 127.0.0.1 do not need them and pay 1-2s per
53
+ register/unregister cycle without this fixture. Opt in by adding
54
+ `quick_timing` to a test's argument list.
55
+ """
56
+ with (
57
+ patch.object(_core, "_CHECK_TIME", 10),
58
+ patch.object(_core, "_REGISTER_TIME", 10),
59
+ patch.object(_core, "_UNREGISTER_TIME", 10),
60
+ ):
61
+ yield
@@ -227,10 +227,7 @@ class TestServiceBrowser(unittest.TestCase):
227
227
  generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE)
228
228
  assert generated.is_response() is True
229
229
 
230
- if service_state_change == r.ServiceStateChange.Removed:
231
- ttl = 0
232
- else:
233
- ttl = 120
230
+ ttl = 0 if service_state_change == r.ServiceStateChange.Removed else 120
234
231
 
235
232
  generated.add_answer_at_time(
236
233
  r.DNSText(
@@ -1575,9 +1572,8 @@ async def test_close_zeroconf_without_browser_before_start_up_queries():
1575
1572
  registration_name = f"xxxyyy.{type_}"
1576
1573
 
1577
1574
  def on_service_state_change(zeroconf, service_type, state_change, name):
1578
- if name == registration_name:
1579
- if state_change is ServiceStateChange.Added:
1580
- service_added.set()
1575
+ if name == registration_name and state_change is ServiceStateChange.Added:
1576
+ service_added.set()
1581
1577
 
1582
1578
  aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
1583
1579
  zeroconf_browser = aiozc.zeroconf
@@ -1644,9 +1640,8 @@ async def test_close_zeroconf_without_browser_after_start_up_queries():
1644
1640
  registration_name = f"xxxyyy.{type_}"
1645
1641
 
1646
1642
  def on_service_state_change(zeroconf, service_type, state_change, name):
1647
- if name == registration_name:
1648
- if state_change is ServiceStateChange.Added:
1649
- service_added.set()
1643
+ if name == registration_name and state_change is ServiceStateChange.Added:
1644
+ service_added.set()
1650
1645
 
1651
1646
  aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
1652
1647
  zeroconf_browser = aiozc.zeroconf
@@ -1050,12 +1050,12 @@ def test_request_timeout():
1050
1050
  """Test that the timeout does not throw an exception and finishes close to the actual timeout."""
1051
1051
  zeroconf = r.Zeroconf(interfaces=["127.0.0.1"])
1052
1052
  start_time = r.current_time_millis()
1053
- assert zeroconf.get_service_info("_notfound.local.", "notthere._notfound.local.") is None
1053
+ assert zeroconf.get_service_info("_notfound.local.", "notthere._notfound.local.", timeout=200) is None
1054
1054
  end_time = r.current_time_millis()
1055
1055
  zeroconf.close()
1056
- # 3000ms for the default timeout
1056
+ # 200ms for the timeout passed above
1057
1057
  # 1000ms for loaded systems + schedule overhead
1058
- assert (end_time - start_time) < 3000 + 1000
1058
+ assert (end_time - start_time) < 200 + 1000
1059
1059
 
1060
1060
 
1061
1061
  @pytest.mark.asyncio
@@ -1888,7 +1888,7 @@ async def test_unicast_flag_if_requested() -> None:
1888
1888
  # patch the zeroconf send
1889
1889
  with patch.object(aiozc.zeroconf, "async_send", async_send):
1890
1890
  await aiozc.async_get_service_info(
1891
- f"willnotbefound.{type_}", type_, question_type=r.DNSQuestionType.QU
1891
+ f"willnotbefound.{type_}", type_, timeout=200, question_type=r.DNSQuestionType.QU
1892
1892
  )
1893
1893
 
1894
1894
  await aiozc.async_close()
@@ -260,7 +260,7 @@ async def test_async_service_registration_with_server_missing() -> None:
260
260
 
261
261
 
262
262
  @pytest.mark.asyncio
263
- async def test_async_service_registration_same_server_different_ports() -> None:
263
+ async def test_async_service_registration_same_server_different_ports(quick_timing: None) -> None:
264
264
  """Test registering services with the same server with different srv records."""
265
265
  aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
266
266
  type_ = "_test1-srvc-type._tcp.local."
@@ -327,7 +327,7 @@ async def test_async_service_registration_same_server_different_ports() -> None:
327
327
 
328
328
 
329
329
  @pytest.mark.asyncio
330
- async def test_async_service_registration_same_server_same_ports() -> None:
330
+ async def test_async_service_registration_same_server_same_ports(quick_timing: None) -> None:
331
331
  """Test registering services with the same server with the exact same srv record."""
332
332
  aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
333
333
  type_ = "_test1-srvc-type._tcp.local."
@@ -468,7 +468,7 @@ async def test_async_service_registration_name_does_not_match_type() -> None:
468
468
 
469
469
 
470
470
  @pytest.mark.asyncio
471
- async def test_async_service_registration_name_strict_check() -> None:
471
+ async def test_async_service_registration_name_strict_check(quick_timing: None) -> None:
472
472
  """Test registering services throws when the name does not comply."""
473
473
  zc = Zeroconf(interfaces=["127.0.0.1"])
474
474
  aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
@@ -824,7 +824,7 @@ async def test_service_browser_cancel_async_context_manager():
824
824
 
825
825
 
826
826
  @pytest.mark.asyncio
827
- async def test_async_unregister_all_services() -> None:
827
+ async def test_async_unregister_all_services(quick_timing: None) -> None:
828
828
  """Test unregistering all services."""
829
829
  aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
830
830
  type_ = "_test1-srvc-type._tcp.local."
@@ -870,8 +870,8 @@ async def test_async_unregister_all_services() -> None:
870
870
  _clear_cache(aiozc.zeroconf)
871
871
 
872
872
  tasks = []
873
- tasks.append(aiozc.async_get_service_info(type_, registration_name))
874
- tasks.append(aiozc.async_get_service_info(type_, registration_name2))
873
+ tasks.append(aiozc.async_get_service_info(type_, registration_name, timeout=200))
874
+ tasks.append(aiozc.async_get_service_info(type_, registration_name2, timeout=200))
875
875
  results = await asyncio.gather(*tasks)
876
876
  assert results[0] is None
877
877
  assert results[1] is None
@@ -883,7 +883,7 @@ async def test_async_unregister_all_services() -> None:
883
883
 
884
884
 
885
885
  @pytest.mark.asyncio
886
- async def test_async_zeroconf_service_types():
886
+ async def test_async_zeroconf_service_types(quick_timing: None) -> None:
887
887
  type_ = "_test-srvc-type._tcp.local."
888
888
  name = "xxxyyy"
889
889
  registration_name = f"{name}.{type_}"
@@ -1276,12 +1276,15 @@ async def test_async_request_timeout():
1276
1276
  aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
1277
1277
  await aiozc.zeroconf.async_wait_for_start()
1278
1278
  start_time = current_time_millis()
1279
- assert await aiozc.async_get_service_info("_notfound.local.", "notthere._notfound.local.") is None
1279
+ assert (
1280
+ await aiozc.async_get_service_info("_notfound.local.", "notthere._notfound.local.", timeout=200)
1281
+ is None
1282
+ )
1280
1283
  end_time = current_time_millis()
1281
1284
  await aiozc.async_close()
1282
- # 3000ms for the default timeout
1285
+ # 200ms for the timeout passed above
1283
1286
  # 1000ms for loaded systems + schedule overhead
1284
- assert (end_time - start_time) < 3000 + 1000
1287
+ assert (end_time - start_time) < 200 + 1000
1285
1288
 
1286
1289
 
1287
1290
  @pytest.mark.asyncio
@@ -12,6 +12,7 @@ import time
12
12
  import unittest
13
13
  import unittest.mock
14
14
  import warnings
15
+ from pathlib import Path
15
16
  from typing import cast
16
17
  from unittest.mock import AsyncMock, Mock, patch
17
18
 
@@ -83,6 +84,40 @@ class Framework(unittest.TestCase):
83
84
  rv.close()
84
85
  rv.close()
85
86
 
87
+ def test_close_releases_owned_event_loop(self):
88
+ """Closing a Zeroconf that started its own loop thread closes that loop.
89
+
90
+ Regression test for issue #1589 — without loop.close(), the selector
91
+ (epoll on Linux) and its self-pipe sockets stay open across each
92
+ Zeroconf construct/close cycle and the process eventually exhausts
93
+ its FD limit.
94
+ """
95
+ rv = r.Zeroconf(interfaces=["127.0.0.1"])
96
+ loop = rv.loop
97
+ assert loop is not None
98
+ assert loop.is_running()
99
+ rv.close()
100
+ assert loop.is_closed()
101
+
102
+ @unittest.skipUnless(sys.platform.startswith("linux"), "Requires /proc/<pid>/fd")
103
+ @unittest.skipUnless(Path(f"/proc/{os.getpid()}/fd").is_dir(), "/proc/<pid>/fd not available")
104
+ def test_close_does_not_leak_file_descriptors(self):
105
+ """Tight loops of Zeroconf()/close() do not leak FDs (issue #1589)."""
106
+ fd_dir = Path(f"/proc/{os.getpid()}/fd")
107
+
108
+ def _fd_count() -> int:
109
+ return sum(1 for _ in fd_dir.iterdir())
110
+
111
+ # Warm-up cycle so any one-shot import-time FDs land before measuring.
112
+ r.Zeroconf(interfaces=["127.0.0.1"]).close()
113
+ baseline = _fd_count()
114
+ for _ in range(10):
115
+ r.Zeroconf(interfaces=["127.0.0.1"]).close()
116
+ # Allow tiny slack for unrelated FDs the test harness may open
117
+ # (e.g. coverage), but reject the per-cycle linear growth pattern
118
+ # the bug produced (~3 FDs per cycle, so >=30 over 10 cycles).
119
+ assert _fd_count() - baseline < 10
120
+
86
121
  @unittest.skipIf(not has_working_ipv6(), "Requires IPv6")
87
122
  @unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled")
88
123
  def test_launch_and_close_v4_v6(self):
@@ -721,7 +756,7 @@ async def test_multiple_sync_instances_stared_from_async_close():
721
756
  await asyncio.sleep(0)
722
757
 
723
758
 
724
- def test_shutdown_while_register_in_process():
759
+ def test_shutdown_while_register_in_process(quick_timing: None) -> None:
725
760
  """Test we can shutdown while registering a service in another thread."""
726
761
 
727
762
  # instantiate a zeroconf instance
@@ -753,11 +788,10 @@ def test_shutdown_while_register_in_process():
753
788
 
754
789
 
755
790
  @pytest.mark.asyncio
756
- @patch("zeroconf._core._STARTUP_TIMEOUT", 0)
757
791
  @patch("zeroconf._core.AsyncEngine._async_setup", new_callable=AsyncMock)
758
792
  async def test_event_loop_blocked(mock_start):
759
793
  """Test we raise NotRunningException when waiting for startup that times out."""
760
794
  aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
761
795
  with pytest.raises(NotRunningException):
762
- await aiozc.zeroconf.async_wait_for_start()
796
+ await aiozc.zeroconf.async_wait_for_start(timeout=0)
763
797
  assert aiozc.zeroconf.started is False
@@ -76,7 +76,7 @@ class TestDunder(unittest.TestCase):
76
76
  def test_dns_question_repr(self):
77
77
  question = r.DNSQuestion("irrelevant", const._TYPE_SRV, const._CLASS_IN | const._CLASS_UNIQUE)
78
78
  repr(question)
79
- assert not question != question
79
+ assert (question != question) is False
80
80
 
81
81
  def test_dns_service_repr(self):
82
82
  service = r.DNSService(
@@ -112,7 +112,7 @@ class TestDunder(unittest.TestCase):
112
112
  addresses=[socket.inet_aton("10.0.1.2")],
113
113
  )
114
114
 
115
- assert not info != info
115
+ assert (info != info) is False
116
116
  repr(info)
117
117
 
118
118
  def test_service_info_text_properties_not_given(self):
@@ -24,7 +24,7 @@ def teardown_module():
24
24
 
25
25
 
26
26
  class Exceptions(unittest.TestCase):
27
- browser = None # type: Zeroconf
27
+ browser: Zeroconf
28
28
 
29
29
  @classmethod
30
30
  def setUpClass(cls):
@@ -123,19 +123,25 @@ async def test_run_coro_with_timeout() -> None:
123
123
  """Test running a coroutine with a timeout raises EventLoopBlocked."""
124
124
  loop = asyncio.get_event_loop()
125
125
  task: asyncio.Task | None = None
126
+ task_created = asyncio.Event()
126
127
 
127
128
  async def _saved_sleep_task():
128
129
  nonlocal task
129
- task = asyncio.create_task(asyncio.sleep(0.2))
130
- assert task is not None
130
+ task = asyncio.create_task(asyncio.sleep(1))
131
+ task_created.set()
131
132
  await task
132
133
 
133
134
  def _run_in_loop():
134
- aioutils.run_coro_with_timeout(_saved_sleep_task(), loop, 0.1)
135
+ aioutils.run_coro_with_timeout(_saved_sleep_task(), loop, 50)
135
136
 
136
137
  with pytest.raises(EventLoopBlocked), patch.object(aioutils, "_LOADED_SYSTEM_TIMEOUT", 0.0):
137
138
  await loop.run_in_executor(None, _run_in_loop)
138
139
 
140
+ # The outer .result() can raise EventLoopBlocked before the loop
141
+ # has scheduled the coroutine — wait until the inner task is
142
+ # created before asserting on it. Use an asyncio.Event so the
143
+ # wait yields back to the loop instead of blocking it.
144
+ await asyncio.wait_for(task_created.wait(), 1.0)
139
145
  assert task is not None
140
146
  # ensure the thread is shutdown
141
147
  task.cancel()
@@ -357,7 +357,7 @@ def test_create_sockets_interfaces_all_unicast():
357
357
  mock_socket = Mock(spec=socket.socket)
358
358
  mock_new_socket.return_value = mock_socket
359
359
 
360
- listen_socket, respond_sockets = r.create_sockets(
360
+ listen_socket, _respond_sockets = r.create_sockets(
361
361
  interfaces=r.InterfaceChoice.All, unicast=True, ip_version=r.IPVersion.All
362
362
  )
363
363
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes