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.
- {thingctx-0.2.2/src/thingctx.egg-info → thingctx-0.2.3}/PKG-INFO +11 -8
- {thingctx-0.2.2 → thingctx-0.2.3}/README-pypi.md +1 -3
- {thingctx-0.2.2 → thingctx-0.2.3}/README.md +19 -19
- {thingctx-0.2.2 → thingctx-0.2.3}/pyproject.toml +10 -7
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/__init__.py +5 -3
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/providers.py +2 -1
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/__init__.py +3 -1
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/__init__.py +10 -2
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/media/backends.py +2 -2
- thingctx-0.2.3/src/thingctx/bindings/builtin/redis.py +294 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/registry.py +10 -4
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/chain.py +30 -5
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/cli.py +12 -2
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/gateways/engine.py +1 -1
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/integrations/mcp.py +179 -23
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/openapi.py +8 -2
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/thing.py +3 -1
- {thingctx-0.2.2 → thingctx-0.2.3/src/thingctx.egg-info}/PKG-INFO +11 -8
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx.egg-info/SOURCES.txt +3 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx.egg-info/requires.txt +10 -4
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_authz_mcp.py +45 -19
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_binding_contract.py +7 -0
- thingctx-0.2.3/tests/test_cli_input_errors.py +82 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_lean_core.py +14 -3
- thingctx-0.2.3/tests/test_mcp_http.py +456 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_media_remux.py +2 -2
- thingctx-0.2.3/tests/test_redis_binding.py +241 -0
- thingctx-0.2.2/tests/test_mcp_http.py +0 -102
- {thingctx-0.2.2 → thingctx-0.2.3}/LICENSE +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/NOTICE +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/setup.cfg +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/__init__.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/context.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/credentials.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/http.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/media.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/mqtt.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/oauth_consent.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/registry.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/resolve.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/secret.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/sigv4.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/auth/store.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/authz/__init__.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/authz/authzen.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/authz/pdp.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/authz/pep.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/authz/vocabulary.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/base.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/exec.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/http.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/local.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/media/__init__.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/media/binding.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/media/encode.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/media/frame.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/media/sample.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/bindings/builtin/mqtt.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/client.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/contracts.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/contrib/__init__.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/contrib/filesystem.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/contrib/llm.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/contrib/time.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/data/td-schema-1.1.json +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/extensions/__init__.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/extensions/prompts.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/gateway.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/gateways/__init__.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/gateways/builtin/mcp.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/gateways/builtin/mqtt.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/gateways/registry.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/identity/__init__.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/identity/guard.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/identity/jwt_guard.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/identity/provider.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/identity/providers/__init__.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/identity/providers/cloudflare.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/identity/providers/entra.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/identity/registry.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/integrations/__init__.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/integrations/connect.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/lifecycle.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/lint.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/netpolicy.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/py.typed +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/quality.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/registry.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/reliability.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/runtime.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/skill/SKILL.md +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/testing.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/trust.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx/validate.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx.egg-info/dependency_links.txt +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx.egg-info/entry_points.txt +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/src/thingctx.egg-info/top_level.txt +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_add_things.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_auth_appliers.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_auth_extensible.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_auth_jwt_bearer.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_auth_sigv4.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_authz.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_authz_agent_identity.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_authz_gateway.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_authz_pdp_parity.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_binding_auth.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_check_reuse.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_cli_invoke.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_cli_registry_skill.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_cloudflare_guard.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_connect.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_core.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_credential_redaction.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_dogfood_surface.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_examples_smoke.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_exec_binding.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_gateway.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_gateways_engine.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_gateways_event_authz.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_gateways_mcp.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_guard.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_hardening.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_hardening_auth_registry.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_http_href_query.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_http_upload.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_invariant_gaps.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_lint.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_local_handlers.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_mcp_bridge.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_mcp_policy.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_media_auth.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_media_autodetect.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_media_binding.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_media_publish.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_media_reliability.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_media_roundtrip_matrix.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_media_timeout.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_media_wiring.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_mqtt_live.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_mqtt_live_reliability.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_mqtt_reliability.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_oauth2.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_oauth2_live.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_oauth_authorization_code.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_openapi.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_openapi_widen.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_packaging_floors.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_provider.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_quality.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_quickstart.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_quiet_startup.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_registry.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_reliability.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_response_chain.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_runtime_coverage.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_scalar_input.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_schema_current.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_secret.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_secret_projection.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_td_coverage.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_time.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_tool_name_conformance.py +0 -0
- {thingctx-0.2.2 → thingctx-0.2.3}/tests/test_trust.py +0 -0
- {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.
|
|
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
|
|
29
|
+
Requires-Dist: mcp<2,>=1.3; extra == "mcp"
|
|
28
30
|
Provides-Extra: mcp-http
|
|
29
|
-
Requires-Dist: mcp
|
|
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
|
|
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
|
|
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.
|
|
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)
|
|
191
|
-
async for evt in await client.subscribe(
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
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(
|
|
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): ...
|
|
378
|
-
async def read(self, prop, form): ...
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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__ = [
|
|
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
|
|
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()
|