lifx-async 5.0.0__tar.gz → 5.0.1__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 (169) hide show
  1. {lifx_async-5.0.0 → lifx_async-5.0.1}/PKG-INFO +1 -1
  2. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/changelog.md +8 -0
  3. {lifx_async-5.0.0 → lifx_async-5.0.1}/pyproject.toml +1 -1
  4. lifx_async-5.0.1/scripts/test_multiversion.py +227 -0
  5. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/const.py +18 -0
  6. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/effects/colorloop.py +2 -2
  7. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/network/connection.py +6 -5
  8. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/network/mdns/transport.py +2 -2
  9. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/network/transport.py +8 -3
  10. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_api/test_api_apply_theme.py +2 -1
  11. {lifx_async-5.0.0 → lifx_async-5.0.1}/uv.lock +1 -1
  12. {lifx_async-5.0.0 → lifx_async-5.0.1}/.claude/settings.json +0 -0
  13. {lifx_async-5.0.0 → lifx_async-5.0.1}/.github/dependabot.yml +0 -0
  14. {lifx_async-5.0.0 → lifx_async-5.0.1}/.github/labeler.yml +0 -0
  15. {lifx_async-5.0.0 → lifx_async-5.0.1}/.github/workflows/ci.yml +0 -0
  16. {lifx_async-5.0.0 → lifx_async-5.0.1}/.github/workflows/docs.yml +0 -0
  17. {lifx_async-5.0.0 → lifx_async-5.0.1}/.github/workflows/pr-automation.yml +0 -0
  18. {lifx_async-5.0.0 → lifx_async-5.0.1}/.gitignore +0 -0
  19. {lifx_async-5.0.0 → lifx_async-5.0.1}/.pre-commit-config.yaml +0 -0
  20. {lifx_async-5.0.0 → lifx_async-5.0.1}/CLAUDE.md +0 -0
  21. {lifx_async-5.0.0 → lifx_async-5.0.1}/LICENSE +0 -0
  22. {lifx_async-5.0.0 → lifx_async-5.0.1}/README.md +0 -0
  23. {lifx_async-5.0.0 → lifx_async-5.0.1}/context7.json +0 -0
  24. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/api/colors.md +0 -0
  25. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/api/devices.md +0 -0
  26. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/api/effects.md +0 -0
  27. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/api/exceptions.md +0 -0
  28. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/api/high-level.md +0 -0
  29. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/api/index.md +0 -0
  30. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/api/network.md +0 -0
  31. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/api/protocol.md +0 -0
  32. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/api/themes.md +0 -0
  33. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/architecture/effects-architecture.md +0 -0
  34. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/architecture/overview.md +0 -0
  35. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/faq.md +0 -0
  36. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/getting-started/effects.md +0 -0
  37. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/getting-started/installation.md +0 -0
  38. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/getting-started/quickstart.md +0 -0
  39. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/getting-started/themes.md +0 -0
  40. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/index.md +0 -0
  41. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/migration/effect-api-changes.md +0 -0
  42. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/stylesheets/extra.css +0 -0
  43. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/user-guide/advanced-usage.md +0 -0
  44. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/user-guide/ceiling-lights.md +0 -0
  45. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/user-guide/effects-custom.md +0 -0
  46. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/user-guide/effects-troubleshooting.md +0 -0
  47. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/user-guide/protocol-deep-dive.md +0 -0
  48. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/user-guide/themes.md +0 -0
  49. {lifx_async-5.0.0 → lifx_async-5.0.1}/docs/user-guide/troubleshooting.md +0 -0
  50. {lifx_async-5.0.0 → lifx_async-5.0.1}/examples/01_simple_discovery.py +0 -0
  51. {lifx_async-5.0.0 → lifx_async-5.0.1}/examples/02_simple_control.py +0 -0
  52. {lifx_async-5.0.0 → lifx_async-5.0.1}/examples/03_waveforms.py +0 -0
  53. {lifx_async-5.0.0 → lifx_async-5.0.1}/examples/04_logging.py +0 -0
  54. {lifx_async-5.0.0 → lifx_async-5.0.1}/examples/06_pulse_effect.py +0 -0
  55. {lifx_async-5.0.0 → lifx_async-5.0.1}/examples/07_colorloop_effect.py +0 -0
  56. {lifx_async-5.0.0 → lifx_async-5.0.1}/examples/08_custom_effect.py +0 -0
  57. {lifx_async-5.0.0 → lifx_async-5.0.1}/examples/09_background_effect.py +0 -0
  58. {lifx_async-5.0.0 → lifx_async-5.0.1}/examples/10_find_specific_devices.py +0 -0
  59. {lifx_async-5.0.0 → lifx_async-5.0.1}/examples/11_matrix_basic.py +0 -0
  60. {lifx_async-5.0.0 → lifx_async-5.0.1}/examples/12_matrix_effects.py +0 -0
  61. {lifx_async-5.0.0 → lifx_async-5.0.1}/examples/13_matrix_large.py +0 -0
  62. {lifx_async-5.0.0 → lifx_async-5.0.1}/examples/14_mdns_discovery.py +0 -0
  63. {lifx_async-5.0.0 → lifx_async-5.0.1}/mkdocs.yml +0 -0
  64. {lifx_async-5.0.0 → lifx_async-5.0.1}/renovate.json +0 -0
  65. {lifx_async-5.0.0 → lifx_async-5.0.1}/scripts/mdns_probe.py +0 -0
  66. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/__init__.py +0 -0
  67. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/api.py +0 -0
  68. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/color.py +0 -0
  69. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/devices/__init__.py +0 -0
  70. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/devices/base.py +0 -0
  71. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/devices/ceiling.py +0 -0
  72. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/devices/hev.py +0 -0
  73. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/devices/infrared.py +0 -0
  74. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/devices/light.py +0 -0
  75. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/devices/matrix.py +0 -0
  76. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/devices/multizone.py +0 -0
  77. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/effects/__init__.py +0 -0
  78. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/effects/base.py +0 -0
  79. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/effects/conductor.py +0 -0
  80. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/effects/const.py +0 -0
  81. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/effects/models.py +0 -0
  82. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/effects/pulse.py +0 -0
  83. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/effects/state_manager.py +0 -0
  84. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/exceptions.py +0 -0
  85. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/network/__init__.py +0 -0
  86. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/network/discovery.py +0 -0
  87. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/network/mdns/__init__.py +0 -0
  88. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/network/mdns/discovery.py +0 -0
  89. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/network/mdns/dns.py +0 -0
  90. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/network/mdns/types.py +0 -0
  91. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/network/message.py +0 -0
  92. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/products/__init__.py +0 -0
  93. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/products/generator.py +0 -0
  94. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/products/quirks.py +0 -0
  95. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/products/registry.py +0 -0
  96. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/protocol/__init__.py +0 -0
  97. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/protocol/base.py +0 -0
  98. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/protocol/generator.py +0 -0
  99. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/protocol/header.py +0 -0
  100. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/protocol/models.py +0 -0
  101. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/protocol/packets.py +0 -0
  102. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/protocol/protocol_types.py +0 -0
  103. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/protocol/serializer.py +0 -0
  104. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/py.typed +0 -0
  105. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/theme/__init__.py +0 -0
  106. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/theme/canvas.py +0 -0
  107. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/theme/generators.py +0 -0
  108. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/theme/library.py +0 -0
  109. {lifx_async-5.0.0 → lifx_async-5.0.1}/src/lifx/theme/theme.py +0 -0
  110. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/__init__.py +0 -0
  111. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/conftest.py +0 -0
  112. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_api/__init__.py +0 -0
  113. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_api/test_api_batch_errors.py +0 -0
  114. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_api/test_api_batch_operations.py +0 -0
  115. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_api/test_api_discovery.py +0 -0
  116. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_api/test_api_organization.py +0 -0
  117. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_color.py +0 -0
  118. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_devices/__init__.py +0 -0
  119. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_devices/conftest.py +0 -0
  120. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_devices/test_base.py +0 -0
  121. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_devices/test_ceiling.py +0 -0
  122. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_devices/test_hev.py +0 -0
  123. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_devices/test_infrared.py +0 -0
  124. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_devices/test_light.py +0 -0
  125. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_devices/test_mac_address.py +0 -0
  126. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_devices/test_matrix.py +0 -0
  127. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_devices/test_multizone.py +0 -0
  128. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_devices/test_state_ceiling.py +0 -0
  129. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_devices/test_state_hev.py +0 -0
  130. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_devices/test_state_infrared.py +0 -0
  131. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_devices/test_state_light.py +0 -0
  132. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_devices/test_state_management.py +0 -0
  133. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_devices/test_state_matrix.py +0 -0
  134. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_devices/test_state_multizone.py +0 -0
  135. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_effects/__init__.py +0 -0
  136. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_effects/test_base.py +0 -0
  137. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_effects/test_capability_filtering.py +0 -0
  138. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_effects/test_colorloop.py +0 -0
  139. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_effects/test_integration.py +0 -0
  140. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_effects/test_models.py +0 -0
  141. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_effects/test_pulse.py +0 -0
  142. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_effects/test_state_manager.py +0 -0
  143. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_network/__init__.py +0 -0
  144. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_network/test_concurrent_requests.py +0 -0
  145. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_network/test_connection.py +0 -0
  146. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_network/test_discovery_devices.py +0 -0
  147. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_network/test_discovery_errors.py +0 -0
  148. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_network/test_mdns/__init__.py +0 -0
  149. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_network/test_mdns/conftest.py +0 -0
  150. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_network/test_mdns/test_discovery.py +0 -0
  151. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_network/test_mdns/test_dns.py +0 -0
  152. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_network/test_mdns/test_transport.py +0 -0
  153. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_network/test_message.py +0 -0
  154. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_network/test_message_advanced.py +0 -0
  155. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_network/test_transport.py +0 -0
  156. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_products/test_product_generator.py +0 -0
  157. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_products/test_registry.py +0 -0
  158. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_protocol/test_generated.py +0 -0
  159. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_protocol/test_header.py +0 -0
  160. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_protocol/test_protocol_generator.py +0 -0
  161. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_protocol/test_serializer.py +0 -0
  162. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_theme/__init__.py +0 -0
  163. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_theme/conftest.py +0 -0
  164. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_theme/test_apply_theme.py +0 -0
  165. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_theme/test_canvas.py +0 -0
  166. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_theme/test_generators.py +0 -0
  167. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_theme/test_library.py +0 -0
  168. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_theme/test_theme.py +0 -0
  169. {lifx_async-5.0.0 → lifx_async-5.0.1}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-async
3
- Version: 5.0.0
3
+ Version: 5.0.1
4
4
  Summary: A modern, type-safe, async Python library for controlling LIFX lights
5
5
  Author-email: Avi Miller <me@dje.li>
6
6
  Maintainer-email: Avi Miller <me@dje.li>
@@ -2,6 +2,14 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v5.0.1 (2026-01-14)
6
+
7
+ ### Bug Fixes
8
+
9
+ - Handle asyncio.TimeoutError on Python 3.10
10
+ ([`4438bc4`](https://github.com/Djelibeybi/lifx-async/commit/4438bc45f19f477b585c6af8cf8cbaf5e9341d14))
11
+
12
+
5
13
  ## v5.0.0 (2026-01-12)
6
14
 
7
15
  ### Features
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lifx-async"
3
- version = "5.0.0"
3
+ version = "5.0.1"
4
4
  description = "A modern, type-safe, async Python library for controlling LIFX lights"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env python3
2
+ """Multi-version Python test runner.
3
+
4
+ This script runs the test suite against multiple Python versions to catch
5
+ version-specific issues (like the asyncio.TimeoutError vs TimeoutError
6
+ difference between Python 3.10 and 3.11+) before pushing to GitHub.
7
+
8
+ Usage:
9
+ # Run tests on all supported versions (default)
10
+ uv run scripts/test_multiversion.py
11
+
12
+ # Run tests on specific versions only
13
+ uv run scripts/test_multiversion.py --versions 3.10 3.14
14
+
15
+ # Run with verbose pytest output
16
+ uv run scripts/test_multiversion.py -v
17
+
18
+ # Run specific test file/pattern
19
+ uv run scripts/test_multiversion.py -- tests/test_network/
20
+
21
+ # Quick mode: skip coverage
22
+ uv run scripts/test_multiversion.py --quick
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import subprocess # nosec B404 - subprocess needed for running uv/pytest
29
+ import sys
30
+ import time
31
+ from dataclasses import dataclass
32
+
33
+ # Python versions supported by this project (from pyproject.toml)
34
+ SUPPORTED_VERSIONS = ["3.10", "3.11", "3.12", "3.13", "3.14"]
35
+
36
+ # Default: test all supported versions
37
+ DEFAULT_VERSIONS = SUPPORTED_VERSIONS
38
+
39
+
40
+ @dataclass
41
+ class TestResult:
42
+ """Result of running tests on a specific Python version."""
43
+
44
+ version: str
45
+ success: bool
46
+ duration: float
47
+ output: str
48
+
49
+
50
+ def check_python_available(version: str) -> bool:
51
+ """Check if a Python version is available via uv."""
52
+ result = subprocess.run( # nosec B603 B607 - trusted uv command
53
+ ["uv", "python", "find", version],
54
+ capture_output=True,
55
+ text=True,
56
+ )
57
+ return result.returncode == 0
58
+
59
+
60
+ def run_tests(
61
+ version: str,
62
+ pytest_args: list[str],
63
+ verbose: bool = False,
64
+ quick: bool = False,
65
+ ) -> TestResult:
66
+ """Run pytest on a specific Python version using uv."""
67
+ start_time = time.time()
68
+
69
+ cmd = [
70
+ "uv",
71
+ "run",
72
+ "--python",
73
+ version,
74
+ "--isolated", # Use isolated environment to avoid conflicts
75
+ "pytest",
76
+ ]
77
+
78
+ # Add coverage unless in quick mode
79
+ if not quick:
80
+ cmd.extend(["--cov=lifx", "--cov-report=term-missing:skip-covered"])
81
+
82
+ # Add verbosity
83
+ if verbose:
84
+ cmd.append("-v")
85
+ else:
86
+ cmd.append("-q")
87
+
88
+ # Add any additional pytest arguments
89
+ cmd.extend(pytest_args)
90
+
91
+ # Ignore animation tests (uncommitted module with known issues)
92
+ cmd.extend(["--ignore=tests/test_animation"])
93
+
94
+ print(f"\n{'=' * 60}")
95
+ print(f"Running tests on Python {version}")
96
+ print(f"{'=' * 60}")
97
+ print(f"Command: {' '.join(cmd)}\n")
98
+
99
+ result = subprocess.run( # nosec B603 - trusted uv/pytest command
100
+ cmd,
101
+ capture_output=not verbose, # Show output in real-time if verbose
102
+ text=True,
103
+ cwd=subprocess.run( # nosec B603 B607 - trusted git command
104
+ ["git", "rev-parse", "--show-toplevel"],
105
+ capture_output=True,
106
+ text=True,
107
+ ).stdout.strip(),
108
+ )
109
+
110
+ duration = time.time() - start_time
111
+
112
+ output = ""
113
+ if not verbose:
114
+ output = result.stdout + result.stderr
115
+
116
+ return TestResult(
117
+ version=version,
118
+ success=result.returncode == 0,
119
+ duration=duration,
120
+ output=output,
121
+ )
122
+
123
+
124
+ def main() -> int:
125
+ """Run tests across multiple Python versions."""
126
+ parser = argparse.ArgumentParser(
127
+ description="Run tests across multiple Python versions",
128
+ formatter_class=argparse.RawDescriptionHelpFormatter,
129
+ epilog=__doc__,
130
+ )
131
+ parser.add_argument(
132
+ "--versions",
133
+ nargs="+",
134
+ default=None,
135
+ help=f"Python versions to test (default: all supported: {SUPPORTED_VERSIONS})",
136
+ )
137
+ parser.add_argument(
138
+ "-v",
139
+ "--verbose",
140
+ action="store_true",
141
+ help="Verbose pytest output",
142
+ )
143
+ parser.add_argument(
144
+ "--quick",
145
+ action="store_true",
146
+ help="Quick mode: skip coverage for faster execution",
147
+ )
148
+ parser.add_argument(
149
+ "pytest_args",
150
+ nargs="*",
151
+ help="Additional arguments to pass to pytest",
152
+ )
153
+
154
+ args = parser.parse_args()
155
+
156
+ # Determine which versions to test
157
+ if args.versions:
158
+ versions = args.versions
159
+ else:
160
+ versions = DEFAULT_VERSIONS
161
+
162
+ # Validate versions
163
+ for version in versions:
164
+ if version not in SUPPORTED_VERSIONS:
165
+ print(
166
+ f"Warning: {version} is not in supported versions {SUPPORTED_VERSIONS}"
167
+ )
168
+
169
+ # Check availability
170
+ available_versions = []
171
+ missing_versions = []
172
+ for version in versions:
173
+ if check_python_available(version):
174
+ available_versions.append(version)
175
+ else:
176
+ missing_versions.append(version)
177
+
178
+ if missing_versions:
179
+ print(f"Missing Python versions: {missing_versions}")
180
+ print("Install with: uv python install " + " ".join(missing_versions))
181
+ if not available_versions:
182
+ return 1
183
+
184
+ print(f"Testing on Python versions: {available_versions}")
185
+
186
+ # Run tests
187
+ results: list[TestResult] = []
188
+ for version in available_versions:
189
+ result = run_tests(
190
+ version,
191
+ args.pytest_args,
192
+ verbose=args.verbose,
193
+ quick=args.quick,
194
+ )
195
+ results.append(result)
196
+
197
+ if not args.verbose:
198
+ # Show last few lines of output for quick feedback
199
+ lines = result.output.strip().split("\n")
200
+ for line in lines[-10:]:
201
+ print(line)
202
+
203
+ # Summary
204
+ print(f"\n{'=' * 60}")
205
+ print("SUMMARY")
206
+ print(f"{'=' * 60}")
207
+
208
+ all_passed = True
209
+ for result in results:
210
+ status = "✓ PASSED" if result.success else "✗ FAILED"
211
+ print(f"Python {result.version}: {status} ({result.duration:.1f}s)")
212
+ if not result.success:
213
+ all_passed = False
214
+
215
+ if missing_versions:
216
+ print(f"\nSkipped (not installed): {missing_versions}")
217
+
218
+ if all_passed:
219
+ print("\n✓ All tests passed across all Python versions!")
220
+ return 0
221
+ else:
222
+ print("\n✗ Some tests failed. Check output above for details.")
223
+ return 1
224
+
225
+
226
+ if __name__ == "__main__":
227
+ sys.exit(main())
@@ -1,5 +1,7 @@
1
1
  # lifx-async constants
2
2
 
3
+ import asyncio
4
+ import sys
3
5
  import uuid
4
6
  from typing import Final
5
7
 
@@ -109,3 +111,19 @@ PROTOCOL_URL: Final[str] = (
109
111
  PRODUCTS_URL: Final[str] = (
110
112
  "https://raw.githubusercontent.com/LIFX/products/refs/heads/master/products.json"
111
113
  )
114
+
115
+ # ============================================================================
116
+ # Python Version Compatibility
117
+ # ============================================================================
118
+
119
+ # On Python 3.10, asyncio.wait_for() raises asyncio.TimeoutError which is NOT
120
+ # a subclass of the built-in TimeoutError. In Python 3.11+, they are unified.
121
+ # Use this tuple with `except TIMEOUT_ERRORS:` to catch timeouts from asyncio
122
+ # operations on all supported Python versions.
123
+ if sys.version_info < (3, 11):
124
+ TIMEOUT_ERRORS: Final[tuple[type[BaseException], ...]] = (
125
+ TimeoutError,
126
+ asyncio.TimeoutError,
127
+ )
128
+ else:
129
+ TIMEOUT_ERRORS: Final[tuple[type[BaseException], ...]] = (TimeoutError,)
@@ -11,7 +11,7 @@ import random
11
11
  from typing import TYPE_CHECKING
12
12
 
13
13
  from lifx.color import HSBK
14
- from lifx.const import KELVIN_NEUTRAL
14
+ from lifx.const import KELVIN_NEUTRAL, TIMEOUT_ERRORS
15
15
  from lifx.effects.base import LIFXEffect
16
16
 
17
17
  if TYPE_CHECKING:
@@ -172,7 +172,7 @@ class EffectColorloop(LIFXEffect):
172
172
  self._stop_event.wait(), timeout=iteration_period
173
173
  )
174
174
  break # Stop event was set
175
- except TimeoutError:
175
+ except TIMEOUT_ERRORS:
176
176
  pass # Normal - continue to next iteration
177
177
 
178
178
  iteration += 1
@@ -17,6 +17,7 @@ from lifx.const import (
17
17
  DEFAULT_MAX_RETRIES,
18
18
  DEFAULT_REQUEST_TIMEOUT,
19
19
  LIFX_UDP_PORT,
20
+ TIMEOUT_ERRORS,
20
21
  )
21
22
  from lifx.exceptions import (
22
23
  LifxConnectionError,
@@ -193,7 +194,7 @@ class DeviceConnection:
193
194
  await asyncio.wait_for(
194
195
  self._receiver_task, timeout=_RECEIVER_SHUTDOWN_TIMEOUT
195
196
  )
196
- except TimeoutError:
197
+ except TIMEOUT_ERRORS:
197
198
  self._receiver_task.cancel()
198
199
  try:
199
200
  await self._receiver_task
@@ -559,7 +560,7 @@ class DeviceConnection:
559
560
  header, payload = await asyncio.wait_for(
560
561
  response_queue.get(), timeout=remaining_time
561
562
  )
562
- except TimeoutError:
563
+ except TIMEOUT_ERRORS:
563
564
  if not has_yielded:
564
565
  # No response this attempt, retry
565
566
  raise TimeoutError(
@@ -603,7 +604,7 @@ class DeviceConnection:
603
604
 
604
605
  # Continue loop to wait for more responses
605
606
 
606
- except TimeoutError as e:
607
+ except TIMEOUT_ERRORS as e:
607
608
  last_error = LifxTimeoutError(str(e))
608
609
  if attempt < max_retries:
609
610
  # Sleep with jitter before retry
@@ -702,7 +703,7 @@ class DeviceConnection:
702
703
  header, _payload = await asyncio.wait_for(
703
704
  response_queue.get(), timeout=current_timeout
704
705
  )
705
- except TimeoutError:
706
+ except TIMEOUT_ERRORS:
706
707
  raise TimeoutError(
707
708
  f"No acknowledgement within {current_timeout:.3f}s "
708
709
  f"(attempt {attempt + 1}/{max_retries + 1})"
@@ -731,7 +732,7 @@ class DeviceConnection:
731
732
  yield True
732
733
  return
733
734
 
734
- except TimeoutError as e:
735
+ except TIMEOUT_ERRORS as e:
735
736
  last_error = LifxTimeoutError(str(e))
736
737
  if attempt < max_retries:
737
738
  # Sleep with jitter before retry
@@ -13,7 +13,7 @@ import struct
13
13
  from asyncio import DatagramTransport
14
14
  from typing import TYPE_CHECKING
15
15
 
16
- from lifx.const import MDNS_ADDRESS, MDNS_PORT
16
+ from lifx.const import MDNS_ADDRESS, MDNS_PORT, TIMEOUT_ERRORS
17
17
  from lifx.exceptions import LifxNetworkError, LifxTimeoutError
18
18
 
19
19
  if TYPE_CHECKING:
@@ -259,7 +259,7 @@ class MdnsTransport:
259
259
  self._protocol.queue.get(), timeout=timeout
260
260
  )
261
261
  return data, addr
262
- except TimeoutError as e:
262
+ except TIMEOUT_ERRORS as e:
263
263
  raise LifxTimeoutError(f"No mDNS data received within {timeout}s") from e
264
264
  except OSError as e:
265
265
  _LOGGER.debug(
@@ -6,7 +6,12 @@ import asyncio
6
6
  import logging
7
7
  from typing import TYPE_CHECKING
8
8
 
9
- from lifx.const import DEFAULT_IP_ADDRESS, MAX_PACKET_SIZE, MIN_PACKET_SIZE
9
+ from lifx.const import (
10
+ DEFAULT_IP_ADDRESS,
11
+ MAX_PACKET_SIZE,
12
+ MIN_PACKET_SIZE,
13
+ TIMEOUT_ERRORS,
14
+ )
10
15
  from lifx.exceptions import LifxNetworkError
11
16
  from lifx.exceptions import LifxTimeoutError as LifxTimeoutError
12
17
 
@@ -208,7 +213,7 @@ class UdpTransport:
208
213
  data, addr = await asyncio.wait_for(
209
214
  self._protocol.queue.get(), timeout=timeout
210
215
  )
211
- except TimeoutError as e:
216
+ except TIMEOUT_ERRORS as e:
212
217
  raise LifxTimeoutError(f"No data received within {timeout}s") from e
213
218
  except OSError as e:
214
219
  _LOGGER.error(
@@ -302,7 +307,7 @@ class UdpTransport:
302
307
  continue
303
308
 
304
309
  packets.append((data, addr))
305
- except TimeoutError:
310
+ except TIMEOUT_ERRORS:
306
311
  # Timeout is expected - return what we collected
307
312
  break
308
313
  except OSError:
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
  import asyncio
12
12
 
13
13
  from lifx.api import DeviceGroup
14
+ from lifx.const import TIMEOUT_ERRORS
14
15
  from lifx.theme import ThemeLibrary
15
16
 
16
17
 
@@ -251,7 +252,7 @@ class TestDeviceGroupApplyTheme:
251
252
  # (will fail at connection level, but our method works)
252
253
  try:
253
254
  await asyncio.wait_for(group.apply_theme(theme), timeout=0.1)
254
- except TimeoutError:
255
+ except TIMEOUT_ERRORS:
255
256
  # Expected - no real devices
256
257
  pass
257
258
 
@@ -391,7 +391,7 @@ wheels = [
391
391
 
392
392
  [[package]]
393
393
  name = "lifx-async"
394
- version = "5.0.0"
394
+ version = "5.0.1"
395
395
  source = { editable = "." }
396
396
 
397
397
  [package.dev-dependencies]
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