thingctx 0.2.2__tar.gz → 0.2.3__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 (165) hide show
  1. {thingctx-0.2.2/src/thingctx.egg-info → thingctx-0.2.3}/PKG-INFO +11 -8
  2. {thingctx-0.2.2 → thingctx-0.2.3}/README-pypi.md +1 -3
  3. {thingctx-0.2.2 → thingctx-0.2.3}/README.md +19 -19
  4. {thingctx-0.2.2 → thingctx-0.2.3}/pyproject.toml +10 -7
  5. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/__init__.py +5 -3
  6. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/providers.py +2 -1
  7. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/__init__.py +3 -1
  8. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/__init__.py +10 -2
  9. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/media/backends.py +2 -2
  10. thingctx-0.2.3/src/thingctx/bindings/builtin/redis.py +294 -0
  11. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/registry.py +10 -4
  12. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/chain.py +30 -5
  13. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/cli.py +12 -2
  14. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/gateways/engine.py +1 -1
  15. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/integrations/mcp.py +179 -23
  16. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/openapi.py +8 -2
  17. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/thing.py +3 -1
  18. {thingctx-0.2.2 → thingctx-0.2.3/src/thingctx.egg-info}/PKG-INFO +11 -8
  19. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx.egg-info/SOURCES.txt +3 -0
  20. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx.egg-info/requires.txt +10 -4
  21. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_authz_mcp.py +45 -19
  22. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_binding_contract.py +7 -0
  23. thingctx-0.2.3/tests/test_cli_input_errors.py +82 -0
  24. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_lean_core.py +14 -3
  25. thingctx-0.2.3/tests/test_mcp_http.py +456 -0
  26. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_media_remux.py +2 -2
  27. thingctx-0.2.3/tests/test_redis_binding.py +241 -0
  28. thingctx-0.2.2/tests/test_mcp_http.py +0 -102
  29. {thingctx-0.2.2 → thingctx-0.2.3}/LICENSE +0 -0
  30. {thingctx-0.2.2 → thingctx-0.2.3}/NOTICE +0 -0
  31. {thingctx-0.2.2 → thingctx-0.2.3}/setup.cfg +0 -0
  32. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/__init__.py +0 -0
  33. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/context.py +0 -0
  34. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/credentials.py +0 -0
  35. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/http.py +0 -0
  36. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/media.py +0 -0
  37. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/mqtt.py +0 -0
  38. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/oauth_consent.py +0 -0
  39. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/registry.py +0 -0
  40. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/resolve.py +0 -0
  41. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/secret.py +0 -0
  42. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/sigv4.py +0 -0
  43. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/store.py +0 -0
  44. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/authz/__init__.py +0 -0
  45. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/authz/authzen.py +0 -0
  46. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/authz/pdp.py +0 -0
  47. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/authz/pep.py +0 -0
  48. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/authz/vocabulary.py +0 -0
  49. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/base.py +0 -0
  50. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/exec.py +0 -0
  51. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/http.py +0 -0
  52. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/local.py +0 -0
  53. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/media/__init__.py +0 -0
  54. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/media/binding.py +0 -0
  55. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/media/encode.py +0 -0
  56. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/media/frame.py +0 -0
  57. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/media/sample.py +0 -0
  58. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/mqtt.py +0 -0
  59. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/client.py +0 -0
  60. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/contracts.py +0 -0
  61. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/contrib/__init__.py +0 -0
  62. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/contrib/filesystem.py +0 -0
  63. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/contrib/llm.py +0 -0
  64. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/contrib/time.py +0 -0
  65. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/data/td-schema-1.1.json +0 -0
  66. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/extensions/__init__.py +0 -0
  67. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/extensions/prompts.py +0 -0
  68. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/gateway.py +0 -0
  69. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/gateways/__init__.py +0 -0
  70. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/gateways/builtin/mcp.py +0 -0
  71. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/gateways/builtin/mqtt.py +0 -0
  72. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/gateways/registry.py +0 -0
  73. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/identity/__init__.py +0 -0
  74. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/identity/guard.py +0 -0
  75. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/identity/jwt_guard.py +0 -0
  76. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/identity/provider.py +0 -0
  77. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/identity/providers/__init__.py +0 -0
  78. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/identity/providers/cloudflare.py +0 -0
  79. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/identity/providers/entra.py +0 -0
  80. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/identity/registry.py +0 -0
  81. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/integrations/__init__.py +0 -0
  82. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/integrations/connect.py +0 -0
  83. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/lifecycle.py +0 -0
  84. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/lint.py +0 -0
  85. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/netpolicy.py +0 -0
  86. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/py.typed +0 -0
  87. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/quality.py +0 -0
  88. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/registry.py +0 -0
  89. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/reliability.py +0 -0
  90. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/runtime.py +0 -0
  91. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/skill/SKILL.md +0 -0
  92. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/testing.py +0 -0
  93. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/trust.py +0 -0
  94. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/validate.py +0 -0
  95. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx.egg-info/dependency_links.txt +0 -0
  96. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx.egg-info/entry_points.txt +0 -0
  97. {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx.egg-info/top_level.txt +0 -0
  98. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_add_things.py +0 -0
  99. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_auth_appliers.py +0 -0
  100. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_auth_extensible.py +0 -0
  101. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_auth_jwt_bearer.py +0 -0
  102. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_auth_sigv4.py +0 -0
  103. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_authz.py +0 -0
  104. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_authz_agent_identity.py +0 -0
  105. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_authz_gateway.py +0 -0
  106. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_authz_pdp_parity.py +0 -0
  107. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_binding_auth.py +0 -0
  108. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_check_reuse.py +0 -0
  109. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_cli_invoke.py +0 -0
  110. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_cli_registry_skill.py +0 -0
  111. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_cloudflare_guard.py +0 -0
  112. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_connect.py +0 -0
  113. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_core.py +0 -0
  114. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_credential_redaction.py +0 -0
  115. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_dogfood_surface.py +0 -0
  116. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_examples_smoke.py +0 -0
  117. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_exec_binding.py +0 -0
  118. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_gateway.py +0 -0
  119. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_gateways_engine.py +0 -0
  120. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_gateways_event_authz.py +0 -0
  121. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_gateways_mcp.py +0 -0
  122. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_guard.py +0 -0
  123. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_hardening.py +0 -0
  124. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_hardening_auth_registry.py +0 -0
  125. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_http_href_query.py +0 -0
  126. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_http_upload.py +0 -0
  127. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_invariant_gaps.py +0 -0
  128. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_lint.py +0 -0
  129. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_local_handlers.py +0 -0
  130. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_mcp_bridge.py +0 -0
  131. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_mcp_policy.py +0 -0
  132. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_media_auth.py +0 -0
  133. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_media_autodetect.py +0 -0
  134. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_media_binding.py +0 -0
  135. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_media_publish.py +0 -0
  136. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_media_reliability.py +0 -0
  137. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_media_roundtrip_matrix.py +0 -0
  138. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_media_timeout.py +0 -0
  139. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_media_wiring.py +0 -0
  140. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_mqtt_live.py +0 -0
  141. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_mqtt_live_reliability.py +0 -0
  142. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_mqtt_reliability.py +0 -0
  143. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_oauth2.py +0 -0
  144. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_oauth2_live.py +0 -0
  145. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_oauth_authorization_code.py +0 -0
  146. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_openapi.py +0 -0
  147. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_openapi_widen.py +0 -0
  148. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_packaging_floors.py +0 -0
  149. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_provider.py +0 -0
  150. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_quality.py +0 -0
  151. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_quickstart.py +0 -0
  152. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_quiet_startup.py +0 -0
  153. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_registry.py +0 -0
  154. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_reliability.py +0 -0
  155. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_response_chain.py +0 -0
  156. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_runtime_coverage.py +0 -0
  157. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_scalar_input.py +0 -0
  158. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_schema_current.py +0 -0
  159. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_secret.py +0 -0
  160. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_secret_projection.py +0 -0
  161. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_td_coverage.py +0 -0
  162. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_time.py +0 -0
  163. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_tool_name_conformance.py +0 -0
  164. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_trust.py +0 -0
  165. {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_trust_mcp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thingctx
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Drive any agent against any W3C WoT Thing, over any transport. No per-integration server.
5
5
  Author: The thingctx Authors
6
6
  License: Apache-2.0
@@ -21,12 +21,14 @@ Provides-Extra: http
21
21
  Requires-Dist: httpx>=0.25; extra == "http"
22
22
  Provides-Extra: mqtt
23
23
  Requires-Dist: paho-mqtt>=2.0; extra == "mqtt"
24
+ Provides-Extra: redis
25
+ Requires-Dist: redis>=5.0; extra == "redis"
24
26
  Provides-Extra: validate
25
27
  Requires-Dist: jsonschema>=4.0; extra == "validate"
26
28
  Provides-Extra: mcp
27
- Requires-Dist: mcp>=1.3; extra == "mcp"
29
+ Requires-Dist: mcp<2,>=1.3; extra == "mcp"
28
30
  Provides-Extra: mcp-http
29
- Requires-Dist: mcp>=1.3; extra == "mcp-http"
31
+ Requires-Dist: mcp<2,>=1.3; extra == "mcp-http"
30
32
  Requires-Dist: uvicorn>=0.27; extra == "mcp-http"
31
33
  Provides-Extra: openapi
32
34
  Requires-Dist: httpx>=0.25; extra == "openapi"
@@ -50,8 +52,9 @@ Provides-Extra: all
50
52
  Requires-Dist: litellm>=1.0; extra == "all"
51
53
  Requires-Dist: httpx>=0.27; extra == "all"
52
54
  Requires-Dist: paho-mqtt>=2.0; extra == "all"
55
+ Requires-Dist: redis>=5.0; extra == "all"
53
56
  Requires-Dist: jsonschema>=4.0; extra == "all"
54
- Requires-Dist: mcp>=1.3; extra == "all"
57
+ Requires-Dist: mcp<2,>=1.3; extra == "all"
55
58
  Requires-Dist: pyyaml>=6; extra == "all"
56
59
  Requires-Dist: pyjwt[crypto]>=2.8; extra == "all"
57
60
  Requires-Dist: azure-identity>=1.20; extra == "all"
@@ -62,8 +65,10 @@ Provides-Extra: dev
62
65
  Requires-Dist: pytest>=7; extra == "dev"
63
66
  Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
64
67
  Requires-Dist: pytest-cov==7.1.0; extra == "dev"
68
+ Requires-Dist: redis>=5.0; extra == "dev"
69
+ Requires-Dist: fakeredis>=2.18; extra == "dev"
65
70
  Requires-Dist: jsonschema>=4.0; extra == "dev"
66
- Requires-Dist: mcp>=1.3; extra == "dev"
71
+ Requires-Dist: mcp<2,>=1.3; extra == "dev"
67
72
  Requires-Dist: httpx>=0.27; extra == "dev"
68
73
  Requires-Dist: pyyaml>=6; extra == "dev"
69
74
  Requires-Dist: pyjwt[crypto]>=2.8; extra == "dev"
@@ -124,9 +129,7 @@ td = {
124
129
 
125
130
 
126
131
  async def main():
127
- client = thingctx.ThingClient(
128
- tds=[td], bindings=[thingctx.LocalBinding(make_time_handler())]
129
- )
132
+ client = thingctx.ThingClient(tds=[td], bindings=[thingctx.LocalBinding(make_time_handler())])
130
133
  tools, invoke = client.as_tools()
131
134
  print("tools:", [t["function"]["name"] for t in tools])
132
135
  print(await invoke("clock__getCurrentTime", {"timezone": "UTC"}))
@@ -40,9 +40,7 @@ td = {
40
40
 
41
41
 
42
42
  async def main():
43
- client = thingctx.ThingClient(
44
- tds=[td], bindings=[thingctx.LocalBinding(make_time_handler())]
45
- )
43
+ client = thingctx.ThingClient(tds=[td], bindings=[thingctx.LocalBinding(make_time_handler())])
46
44
  tools, invoke = client.as_tools()
47
45
  print("tools:", [t["function"]["name"] for t in tools])
48
46
  print(await invoke("clock__getCurrentTime", {"timezone": "UTC"}))
@@ -84,9 +84,7 @@ TD = {
84
84
 
85
85
 
86
86
  async def main():
87
- client = thingctx.ThingClient(
88
- tds=[TD], bindings=[thingctx.LocalBinding(make_time_handler())]
89
- )
87
+ client = thingctx.ThingClient(tds=[TD], bindings=[thingctx.LocalBinding(make_time_handler())])
90
88
  tools, invoke = client.as_tools() # specs for your model; invoke runs a call
91
89
  print("tools:", [t["function"]["name"] for t in tools])
92
90
  print(await invoke("time__getCurrentTime", {"timezone": "UTC"}))
@@ -113,7 +111,8 @@ pip install 'thingctx[llm,http,validate]'
113
111
 
114
112
  Quote the argument; unquoted brackets fail in zsh. Base
115
113
  `pip install thingctx` has no dependencies; it already includes the `local`
116
- and `exec` transports. Every optional transport and capability has an extra,
114
+ and `exec` transports. Redis support is `pip install 'thingctx[redis]'`. Every optional
115
+ transport and capability has an extra,
117
116
  listed in [`pyproject.toml`](pyproject.toml); each one this page uses is
118
117
  named next to the code that needs it.
119
118
 
@@ -187,15 +186,17 @@ async def run(): # a sketch, not a program: call it from your own loop
187
186
 
188
187
  await invoke("pump__set_speed", {"rpm": 1500})
189
188
  await client.read_property("pump__rpm")
190
- await client.write_property("pump__target_rpm", 1500) # gated like invoke
191
- async for evt in await client.subscribe("pump__overheat"): # subscribe returns an async iterator
189
+ await client.write_property("pump__target_rpm", 1500) # gated like invoke
190
+ async for evt in await client.subscribe(
191
+ "pump__overheat"
192
+ ): # subscribe returns an async iterator
192
193
  ...
193
194
  ```
194
195
 
195
- The form picks the transport per call, so one client can read over HTTP and
196
- subscribe over MQTT. Bindings that pull optional dependencies, MQTT among
197
- them, are off by default: install the extra and pass
198
- `bindings=thingctx.BindingRegistry.default(mqtt=True)`.
196
+ The form picks the transport per call, so one client can read over HTTP, subscribe
197
+ over MQTT, and read or write cached properties over Redis. Bindings that pull optional
198
+ dependencies, including MQTT and Redis, are off by default: install the extra and pass
199
+ `bindings=thingctx.BindingRegistry.default(mqtt=True, redis=True)`.
199
200
 
200
201
  Want the loop handled for you? The `llm` extra runs any provider through
201
202
  litellm; add the `http` extra, since the weather file's form is HTTPS. Set
@@ -242,6 +243,7 @@ Carrying on from the first example above, with the same `TD`:
242
243
  def approve(req): # sync or async; return True to allow
243
244
  return input(f"run {req.tool_name}{req.arguments}? [y/N] ").lower() == "y"
244
245
 
246
+
245
247
  client = thingctx.ThingClient(
246
248
  tds=[TD],
247
249
  bindings=[thingctx.LocalBinding(make_time_handler())],
@@ -262,18 +264,16 @@ property, `target_rpm`.
262
264
  from thingctx import LocalBinding, ThingClient
263
265
  from thingctx.authz import LocalPolicyGrantSource, PolicyDecisionPoint, build_vocabulary
264
266
 
265
- reader = ThingClient(tds=[TD], bindings=[LocalBinding(Pump())]) # no pdp yet; parsed only to read the description
267
+ reader = ThingClient(
268
+ tds=[TD], bindings=[LocalBinding(Pump())]
269
+ ) # no pdp yet; parsed only to read the description
266
270
  vocab = build_vocabulary(reader.things) # the closed set the description declares
267
271
 
268
- grants = LocalPolicyGrantSource(
269
- {"operator": {("urn:demo:pump", "target_rpm", "readproperty")}}
270
- )
272
+ grants = LocalPolicyGrantSource({"operator": {("urn:demo:pump", "target_rpm", "readproperty")}})
271
273
  pdp = PolicyDecisionPoint(vocabulary=vocab, grant_source=grants)
272
274
  identity = {"sub": "alice", "roles": ["operator"]} # claims, validated upstream
273
275
 
274
- client = ThingClient(
275
- tds=[TD], bindings=[LocalBinding(Pump())], pdp=pdp, identity=identity
276
- )
276
+ client = ThingClient(tds=[TD], bindings=[LocalBinding(Pump())], pdp=pdp, identity=identity)
277
277
  ```
278
278
 
279
279
  Alice's `operator` role grants read on `target_rpm` and nothing else, so her
@@ -374,8 +374,8 @@ A transport is the common case:
374
374
  class CoapBinding:
375
375
  scheme = "coap"
376
376
 
377
- async def invoke(self, action, form, arguments): ... # required
378
- async def read(self, prop, form): ... # optional
377
+ async def invoke(self, action, form, arguments): ... # required
378
+ async def read(self, prop, form): ... # optional
379
379
  ```
380
380
 
381
381
  The contract is a `scheme` plus an async `invoke`. Add `read`, `write`, or
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "thingctx"
9
- version = "0.2.2"
9
+ version = "0.2.3"
10
10
  description = "Drive any agent against any W3C WoT Thing, over any transport. No per-integration server."
11
11
  readme = "README-pypi.md"
12
12
  requires-python = ">=3.10"
@@ -33,13 +33,16 @@ llm = ["litellm>=1.0"]
33
33
  # Transports.
34
34
  http = ["httpx>=0.25"]
35
35
  mqtt = ["paho-mqtt>=2.0"]
36
+ redis = ["redis>=5.0"]
36
37
  # Validate TDs against the official W3C WoT TD 1.1 schema.
37
38
  validate = ["jsonschema>=4.0"]
38
- # Expose a Thing (or a whole fleet of TDs) to any MCP client.
39
- mcp = ["mcp>=1.3"]
39
+ # Expose a Thing (or a whole fleet of TDs) to any MCP client. Held below 2
40
+ # until the bridge is ported: 2.0 renames the tool and annotation fields,
41
+ # drops the server decorators, and moves the in-memory test helper.
42
+ mcp = ["mcp>=1.3,<2"]
40
43
  # Serve the bridge over streamable HTTP (thingctx-mcp --http) for a hosted gateway or
41
44
  # a cloud agent runtime that only takes a remote MCP URL. starlette ships with mcp.
42
- mcp-http = ["mcp>=1.3", "uvicorn>=0.27"]
45
+ mcp-http = ["mcp>=1.3,<2", "uvicorn>=0.27"]
43
46
  # Import OpenAPI specs (YAML support; JSON works without it).
44
47
  openapi = ["httpx>=0.25", "pyyaml>=6"]
45
48
  # Cloud auth: OAuth2 JWT-bearer assertions need RS256 JWT signing (pyjwt);
@@ -59,14 +62,14 @@ entra = ["azure-identity>=1.20", "pyjwt[crypto]>=2.8", "httpx>=0.27"]
59
62
  # still refuses every call until THINGCTX_FS_ROOT is set.
60
63
  filesystem = []
61
64
  # Everything.
62
- all = ["litellm>=1.0", "httpx>=0.27", "paho-mqtt>=2.0", "jsonschema>=4.0", "mcp>=1.3", "pyyaml>=6", "pyjwt[crypto]>=2.8", "azure-identity>=1.20", "av>=11", "numpy>=1.23", "pillow>=10"]
65
+ all = ["litellm>=1.0", "httpx>=0.27", "paho-mqtt>=2.0", "redis>=5.0", "jsonschema>=4.0", "mcp>=1.3,<2", "pyyaml>=6", "pyjwt[crypto]>=2.8", "azure-identity>=1.20", "av>=11", "numpy>=1.23", "pillow>=10"]
63
66
  # The gate tools are pinned, not floated: the CI and the pre-commit hook must
64
67
  # agree with what a contributor runs locally, or "green here, red in CI" churn
65
- # follows. ruff 0.14.x is where the in-function-import rule (PLC0415) is stable.
68
+ # follows.
66
69
  # botocore is here only as an independent oracle for the SigV4 signer: the test
67
70
  # compares our signature against botocore's canonical one. Without it installed
68
71
  # that comparison skips, so the signing code goes unchecked.
69
- dev = ["pytest>=7", "pytest-asyncio>=0.21", "pytest-cov==7.1.0", "jsonschema>=4.0", "mcp>=1.3", "httpx>=0.27", "pyyaml>=6", "pyjwt[crypto]>=2.8", "azure-identity>=1.20", "paho-mqtt>=2.0", "av>=11", "numpy>=1.23", "pillow>=10", "botocore>=1.34", "ruff==0.16.0", "mypy==2.3.0", "vulture==2.16", "bandit==1.9.4", "pip-audit==2.10.1", "import-linter==2.13", "diff-cover==10.4.1"]
72
+ dev = ["pytest>=7", "pytest-asyncio>=0.21", "pytest-cov==7.1.0", "redis>=5.0", "fakeredis>=2.18", "jsonschema>=4.0", "mcp>=1.3,<2", "httpx>=0.27", "pyyaml>=6", "pyjwt[crypto]>=2.8", "azure-identity>=1.20", "paho-mqtt>=2.0", "av>=11", "numpy>=1.23", "pillow>=10", "botocore>=1.34", "ruff==0.16.0", "mypy==2.3.0", "vulture==2.16", "bandit==1.9.4", "pip-audit==2.10.1", "import-linter==2.13", "diff-cover==10.4.1"]
70
73
 
71
74
  [project.scripts]
72
75
  # Serve a Thing (or a whole fleet of TDs) to any MCP client.
@@ -3,7 +3,7 @@
3
3
  """Consume a WoT Thing Description and drive the Thing over any transport.
4
4
 
5
5
  Parse a TD, present its actions as tools, and invoke each over the
6
- transport its form names. Depends on stdlib; litellm, httpx, paho-mqtt
6
+ transport its form names. Depends on stdlib; litellm, httpx, paho-mqtt, and redis-py
7
7
  are optional extras.
8
8
 
9
9
  import thingctx
@@ -57,7 +57,7 @@ from thingctx.auth import (
57
57
  sigv4_sign,
58
58
  )
59
59
 
60
- # Binding contract: built-in transports (http, mqtt, media, local, exec) implement it;
60
+ # Binding contract: built-in transports (http, mqtt, redis, media, local, exec) implement it;
61
61
  # an adopter registers their own binding to replace one or add a new protocol.
62
62
  from thingctx.bindings import (
63
63
  BUILTIN_BINDINGS,
@@ -79,6 +79,7 @@ from thingctx.bindings import (
79
79
  MqttBinding,
80
80
  ProtocolBinding,
81
81
  Readable,
82
+ RedisBinding,
82
83
  SecurityAware,
83
84
  Subscribable,
84
85
  Writable,
@@ -132,7 +133,7 @@ from thingctx.trust import (
132
133
  )
133
134
  from thingctx.validate import TDValidationError, validate_td
134
135
 
135
- __version__ = "0.2.2"
136
+ __version__ = "0.2.3"
136
137
 
137
138
  __all__ = [
138
139
  "BUILTIN_BINDINGS",
@@ -184,6 +185,7 @@ __all__ = [
184
185
  "ProtocolBinding",
185
186
  "Quality",
186
187
  "Readable",
188
+ "RedisBinding",
187
189
  "Registry",
188
190
  "RequestSigner",
189
191
  "RetryPolicy",
@@ -277,6 +277,7 @@ class OAuth2ClientCredentialsAuth(BaseAuth):
277
277
  secret: str | None,
278
278
  grant: str,
279
279
  scopes: tuple[str, ...],
280
+ *,
280
281
  owner: dict[str, Any] | None = None,
281
282
  ) -> tuple[dict[str, Any], dict[str, str]]:
282
283
  data: dict[str, Any] = {"grant_type": grant}
@@ -331,7 +332,7 @@ class OAuth2ClientCredentialsAuth(BaseAuth):
331
332
  tok = None
332
333
  async with httpx.AsyncClient(timeout=ctx.timeout) as client:
333
334
  for i, method in enumerate(methods):
334
- data, headers = self._token_request(method, cid, secret, grant, scopes, owner)
335
+ data, headers = self._token_request(method, cid, secret, grant, scopes, owner=owner)
335
336
  resp = await client.post(token_url, data=data, headers=headers)
336
337
  if resp.status_code in (400, 401) and i < len(methods) - 1:
337
338
  continue
@@ -4,7 +4,7 @@
4
4
 
5
5
  A binding speaks one transport scheme and answers the runtime through the
6
6
  :class:`ProtocolBinding` contract (a ``scheme`` plus an async ``invoke``, with
7
- optional read / write / subscribe / media capabilities). http, mqtt, media, local,
7
+ optional read / write / subscribe / media capabilities). http, mqtt, redis, media, local,
8
8
  and exec are the built-in bindings, shipped under :mod:`thingctx.bindings.builtin`;
9
9
  each is only an implementation of the contract. Register your own binding to add
10
10
  a protocol thingctx has never heard of, or to replace a built-in, with no fork.
@@ -40,6 +40,7 @@ from thingctx.bindings.builtin.http import HttpBinding
40
40
  from thingctx.bindings.builtin.local import LocalBinding
41
41
  from thingctx.bindings.builtin.media import Frame, MediaBackend, MediaBinding, is_media_form
42
42
  from thingctx.bindings.builtin.mqtt import MqttBinding
43
+ from thingctx.bindings.builtin.redis import RedisBinding
43
44
  from thingctx.bindings.registry import (
44
45
  BUILTIN_BINDINGS,
45
46
  CONTRACT_VERSION,
@@ -76,6 +77,7 @@ __all__ = [
76
77
  # Contract
77
78
  "ProtocolBinding",
78
79
  "Readable",
80
+ "RedisBinding",
79
81
  "SecurityAware",
80
82
  "Subscribable",
81
83
  "Writable",
@@ -1,6 +1,6 @@
1
1
  # Copyright 2026 The thingctx Authors
2
2
  # SPDX-License-Identifier: Apache-2.0
3
- """The built-in bindings thingctx ships: http, mqtt, media, local, and exec.
3
+ """The built-in bindings thingctx ships: http, mqtt, redis, media, local, and exec.
4
4
 
5
5
  Each is an implementation of the :class:`~thingctx.bindings.base.ProtocolBinding`
6
6
  contract, privileged only by being bundled. An adopter can replace any of
@@ -17,5 +17,13 @@ from thingctx.bindings.builtin.http import HttpBinding
17
17
  from thingctx.bindings.builtin.local import LocalBinding
18
18
  from thingctx.bindings.builtin.media import MediaBinding
19
19
  from thingctx.bindings.builtin.mqtt import MqttBinding
20
+ from thingctx.bindings.builtin.redis import RedisBinding
20
21
 
21
- __all__ = ["ExecBinding", "HttpBinding", "LocalBinding", "MediaBinding", "MqttBinding"]
22
+ __all__ = [
23
+ "ExecBinding",
24
+ "HttpBinding",
25
+ "LocalBinding",
26
+ "MediaBinding",
27
+ "MqttBinding",
28
+ "RedisBinding",
29
+ ]
@@ -683,7 +683,7 @@ class ExtractorBackend(PyAVBackend):
683
683
  with the URL to the open."""
684
684
  info = self._resolve_info(url, options)
685
685
  requested = info.get("requested_formats")
686
- formats = requested if requested else [info]
686
+ formats = requested or [info]
687
687
  return [(f["url"], f.get("http_headers") or {}) for f in formats]
688
688
 
689
689
  def _download_format(
@@ -759,7 +759,7 @@ class ExtractorBackend(PyAVBackend):
759
759
  tmpdir = tempfile.mkdtemp(prefix="thingctx-dl-")
760
760
  try:
761
761
  local: list[tuple[str, dict | None]] = [
762
- (self._download_format(url, f["format_id"], tmpdir, options, stop), None)
762
+ (self._download_format(url, f["format_id"], tmpdir, options, stop=stop), None)
763
763
  for f in requested
764
764
  ]
765
765
  super()._mux(local, target, options=opts, stop=stop)
@@ -0,0 +1,294 @@
1
+ # Copyright 2026 The thingctx Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """RedisBinding: read/write Redis keys and subscribe to Redis channels."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import contextlib
8
+ import inspect
9
+ import json
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING, Any
12
+ from urllib.parse import parse_qs, unquote, urlsplit, urlunsplit
13
+
14
+ from thingctx.bindings.base import ProtocolBinding
15
+ from thingctx.contracts import implements
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import AsyncIterator, Callable
19
+
20
+ from thingctx.thing import WoTAction, WoTForm, WoTProperty
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class RedisEndpoint:
25
+ """One parsed Redis form endpoint.
26
+
27
+ ``connection_url`` identifies the Redis server and logical database.
28
+ ``resource`` is interpreted as a Redis key by ``read``/``write`` and as a
29
+ pub/sub channel by ``subscribe``.
30
+ """
31
+
32
+ connection_url: str
33
+ resource: str
34
+
35
+
36
+ def _parse_endpoint(href: str) -> RedisEndpoint:
37
+ """Parse ``redis[s]://host[:port]/<resource>[?db=N]``.
38
+
39
+ The path names the resource rather than Redis' conventional database-number
40
+ path because a WoT form needs to identify the key/channel it drives. Select
41
+ a logical Redis database with ``?db=N`` instead. The form must not embed
42
+ credentials; pass those when constructing the binding instead.
43
+ """
44
+
45
+ parts = urlsplit(href)
46
+ if parts.scheme not in {"redis", "rediss"}:
47
+ raise ValueError("Redis form href must use redis:// or rediss://")
48
+ if not parts.hostname:
49
+ raise ValueError("Redis form href must include a hostname")
50
+ if parts.username is not None or parts.password is not None:
51
+ raise ValueError("Redis form href must not embed credentials; pass them to RedisBinding")
52
+ if parts.fragment:
53
+ raise ValueError("Redis form href must not include a fragment")
54
+
55
+ resource = unquote(parts.path.lstrip("/"))
56
+ if not resource:
57
+ raise ValueError("Redis form href must include a key or channel in its path")
58
+
59
+ query = parse_qs(parts.query, keep_blank_values=True)
60
+ unknown = set(query) - {"db"}
61
+ if unknown:
62
+ names = ", ".join(sorted(unknown))
63
+ raise ValueError(f"unsupported Redis form query parameter(s): {names}")
64
+
65
+ db_values = query.get("db", ["0"])
66
+ if len(db_values) != 1:
67
+ raise ValueError("Redis form db must be specified at most once")
68
+ try:
69
+ db = int(db_values[0])
70
+ except ValueError as exc:
71
+ raise ValueError("Redis form db must be a non-negative integer") from exc
72
+ if db < 0:
73
+ raise ValueError("Redis form db must be a non-negative integer")
74
+
75
+ # Rebuild a redis-py connection URL whose path is the logical DB. The form's
76
+ # original path is the thingctx resource and is intentionally not forwarded.
77
+ connection_url = urlunsplit((parts.scheme, parts.netloc, f"/{db}", "", ""))
78
+ return RedisEndpoint(connection_url=connection_url, resource=resource)
79
+
80
+
81
+ def _schema_type(schema: Any) -> str | None:
82
+ """Return a single declared JSON-schema type when one is unambiguous."""
83
+
84
+ if not isinstance(schema, dict):
85
+ return None
86
+ raw = schema.get("type")
87
+ if isinstance(raw, str):
88
+ return raw
89
+ if isinstance(raw, list):
90
+ concrete = [item for item in raw if isinstance(item, str) and item != "null"]
91
+ if len(concrete) == 1:
92
+ return concrete[0]
93
+ return None
94
+
95
+
96
+ def _target_schema(target: Any) -> dict[str, Any] | None:
97
+ """Return a property's value schema or an event's data schema."""
98
+
99
+ schema = getattr(target, "schema", None)
100
+ if isinstance(schema, dict):
101
+ return schema
102
+ data_schema = getattr(target, "data_schema", None)
103
+ return data_schema if isinstance(data_schema, dict) else None
104
+
105
+
106
+ def _decode_redis(value: Any, schema: dict[str, Any] | None = None) -> Any:
107
+ """Decode Redis bytes into the most useful Python value.
108
+
109
+ Redis returns bytes by default. A TD-declared ``string`` remains a string;
110
+ JSON-shaped data for number/boolean/object/array schemas is JSON-decoded.
111
+ Without a schema, valid JSON is decoded heuristically and other UTF-8 stays
112
+ text. Non-UTF-8 payloads stay bytes.
113
+ """
114
+
115
+ if value is None or not isinstance(value, bytes | bytearray | memoryview):
116
+ return value
117
+
118
+ raw = bytes(value)
119
+ try:
120
+ text = raw.decode("utf-8")
121
+ except UnicodeDecodeError:
122
+ return raw
123
+
124
+ if _schema_type(schema) == "string":
125
+ return text
126
+
127
+ try:
128
+ return json.loads(text)
129
+ except json.JSONDecodeError:
130
+ return text
131
+
132
+
133
+ def _encode_redis(value: Any, schema: dict[str, Any] | None = None) -> bytes:
134
+ """Encode a Python value for a Redis ``SET`` or ``PUBLISH`` payload.
135
+
136
+ Strings are written as ordinary UTF-8 so non-thingctx Redis clients see the
137
+ expected value. Structured/scalar JSON values are compact-JSON encoded;
138
+ bytes pass through unchanged. The TD schema lets the read side preserve a
139
+ numeric-looking string such as ``"42"`` as a string.
140
+ """
141
+
142
+ if isinstance(value, bytes | bytearray | memoryview):
143
+ return bytes(value)
144
+ if isinstance(value, str):
145
+ return value.encode("utf-8")
146
+ try:
147
+ return json.dumps(
148
+ value,
149
+ ensure_ascii=False,
150
+ separators=(",", ":"),
151
+ allow_nan=False,
152
+ ).encode("utf-8")
153
+ except (TypeError, ValueError) as exc:
154
+ kind = _schema_type(schema) or "Redis"
155
+ raise TypeError(f"{kind} value must be bytes, text, or JSON-serializable") from exc
156
+
157
+
158
+ async def _close_resource(resource: Any) -> None:
159
+ """Close redis-py 5.x (``close``) or newer (``aclose``) resources."""
160
+
161
+ closer = getattr(resource, "aclose", None) or getattr(resource, "close", None)
162
+ if not callable(closer):
163
+ return
164
+ result = closer()
165
+ if inspect.isawaitable(result):
166
+ await result
167
+
168
+
169
+ @implements(ProtocolBinding)
170
+ class RedisBinding:
171
+ """Drive Redis keys and pub/sub channels with redis-py's asyncio client.
172
+
173
+ A form uses ``redis://host[:port]/<name>[?db=N]`` (or ``rediss://`` for
174
+ TLS). The WoT affordance decides what ``<name>`` addresses:
175
+
176
+ * property ``read`` / ``write`` -> a Redis key (``GET`` / ``SET``);
177
+ * event/observable ``subscribe`` -> a Redis pub/sub channel (``SUBSCRIBE``).
178
+
179
+ The URL therefore does not guess whether a name is a key or a channel. The
180
+ Thing Description operation supplies that meaning. Redis action invocation
181
+ has no mapping in this binding and raises ``NotImplementedError``.
182
+
183
+ Credentials belong in binding configuration, not in a Thing Description:
184
+ pass redis-py connection options such as ``username`` / ``password`` via
185
+ ``connection_kwargs`` instead of embedding them in the form URL.
186
+ """
187
+
188
+ scheme = "redis"
189
+ schemes = ("redis", "rediss")
190
+
191
+ def __init__(
192
+ self,
193
+ *,
194
+ connection_kwargs: dict[str, Any] | None = None,
195
+ client_factory: Callable[..., Any] | None = None,
196
+ ) -> None:
197
+ self._connection_kwargs = dict(connection_kwargs or {})
198
+ self._client_factory = client_factory
199
+ self._clients: dict[str, Any] = {}
200
+
201
+ def _new_client(self, connection_url: str) -> Any:
202
+ options = {**self._connection_kwargs, "decode_responses": False}
203
+ if self._client_factory is not None:
204
+ return self._client_factory(connection_url, **options)
205
+
206
+ # Optional dependency: importing thingctx must not import redis-py.
207
+ import redis.asyncio as redis # noqa: PLC0415
208
+
209
+ return redis.from_url(connection_url, **options)
210
+
211
+ def _client(self, endpoint: RedisEndpoint) -> Any:
212
+ client = self._clients.get(endpoint.connection_url)
213
+ if client is None:
214
+ client = self._new_client(endpoint.connection_url)
215
+ self._clients[endpoint.connection_url] = client
216
+ return client
217
+
218
+ async def invoke(
219
+ self,
220
+ action: WoTAction,
221
+ form: WoTForm,
222
+ arguments: dict[str, Any],
223
+ ) -> Any:
224
+ del action, form, arguments
225
+ raise NotImplementedError(
226
+ "RedisBinding maps properties to keys and subscriptions to channels; "
227
+ "Redis actions are not supported"
228
+ )
229
+
230
+ async def read(self, prop: WoTProperty, form: WoTForm) -> Any:
231
+ """GET the Redis key named by the form path."""
232
+
233
+ endpoint = _parse_endpoint(form.href)
234
+ value = await self._client(endpoint).get(endpoint.resource)
235
+ return _decode_redis(value, _target_schema(prop))
236
+
237
+ async def write(self, prop: WoTProperty, form: WoTForm, value: Any) -> Any:
238
+ """SET the Redis key named by the form path."""
239
+
240
+ endpoint = _parse_endpoint(form.href)
241
+ payload = _encode_redis(value, _target_schema(prop))
242
+ ok = await self._client(endpoint).set(endpoint.resource, payload)
243
+ return {"ok": bool(ok)}
244
+
245
+ async def subscribe(
246
+ self,
247
+ target: Any,
248
+ form: WoTForm,
249
+ args: dict[str, Any] | None = None,
250
+ ) -> AsyncIterator[Any]:
251
+ """SUBSCRIBE to the Redis channel named by the form path."""
252
+
253
+ if args:
254
+ raise ValueError("Redis subscriptions do not accept subscription arguments")
255
+
256
+ endpoint = _parse_endpoint(form.href)
257
+ pubsub = self._client(endpoint).pubsub()
258
+ try:
259
+ await pubsub.subscribe(endpoint.resource)
260
+ except BaseException:
261
+ await _close_resource(pubsub)
262
+ raise
263
+
264
+ schema = _target_schema(target)
265
+
266
+ async def _stream() -> AsyncIterator[Any]:
267
+ try:
268
+ while True:
269
+ message = await pubsub.get_message(
270
+ ignore_subscribe_messages=True,
271
+ timeout=None,
272
+ )
273
+ if message is not None and message.get("type") in {"message", "pmessage"}:
274
+ yield _decode_redis(message.get("data"), schema)
275
+ finally:
276
+ with contextlib.suppress(Exception):
277
+ await pubsub.unsubscribe(endpoint.resource)
278
+ with contextlib.suppress(Exception):
279
+ await _close_resource(pubsub)
280
+
281
+ return _stream()
282
+
283
+ async def aclose(self) -> None:
284
+ """Close every pooled Redis client. Safe to call more than once."""
285
+
286
+ clients, self._clients = list(self._clients.values()), {}
287
+ for client in clients:
288
+ await _close_resource(client)
289
+
290
+ async def __aenter__(self) -> RedisBinding:
291
+ return self
292
+
293
+ async def __aexit__(self, *exc: Any) -> None:
294
+ await self.aclose()