phystack-hub-client 0.2.4__tar.gz → 6.18.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 (53) hide show
  1. phystack_hub_client-6.18.1/CHANGELOG.md +65 -0
  2. {phystack_hub_client-0.2.4 → phystack_hub_client-6.18.1}/PKG-INFO +37 -7
  3. {phystack_hub_client-0.2.4 → phystack_hub_client-6.18.1}/README.md +29 -0
  4. {phystack_hub_client-0.2.4 → phystack_hub_client-6.18.1}/pyproject.toml +20 -6
  5. {phystack_hub_client-0.2.4 → phystack_hub_client-6.18.1}/src/phystack/hub_client/__init__.py +95 -2
  6. phystack_hub_client-6.18.1/src/phystack/hub_client/advisory_validation.py +114 -0
  7. phystack_hub_client-6.18.1/src/phystack/hub_client/client.py +1444 -0
  8. phystack_hub_client-6.18.1/src/phystack/hub_client/cloud_twins.py +173 -0
  9. {phystack_hub_client-0.2.4 → phystack_hub_client-6.18.1}/src/phystack/hub_client/instances/base_instance.py +91 -21
  10. phystack_hub_client-6.18.1/src/phystack/hub_client/instances/edge_instance.py +332 -0
  11. phystack_hub_client-6.18.1/src/phystack/hub_client/instances/peripheral_instance.py +486 -0
  12. phystack_hub_client-6.18.1/src/phystack/hub_client/scoped_hardware_id.py +103 -0
  13. phystack_hub_client-6.18.1/src/phystack/hub_client/services/cloud_app_connection.py +365 -0
  14. phystack_hub_client-6.18.1/src/phystack/hub_client/services/cloud_app_token.py +198 -0
  15. {phystack_hub_client-0.2.4 → phystack_hub_client-6.18.1}/src/phystack/hub_client/services/phyhub_connection.py +99 -6
  16. {phystack_hub_client-0.2.4 → phystack_hub_client-6.18.1}/src/phystack/hub_client/services/signals.py +128 -17
  17. phystack_hub_client-6.18.1/src/phystack/hub_client/services/web_session_code.py +468 -0
  18. {phystack_hub_client-0.2.4 → phystack_hub_client-6.18.1}/src/phystack/hub_client/services/webrtc/__init__.py +4 -0
  19. phystack_hub_client-6.18.1/src/phystack/hub_client/services/webrtc/data_channel.py +836 -0
  20. phystack_hub_client-6.18.1/src/phystack/hub_client/services/webrtc/data_responder.py +546 -0
  21. phystack_hub_client-6.18.1/src/phystack/hub_client/services/webrtc/media_responder.py +622 -0
  22. phystack_hub_client-6.18.1/src/phystack/hub_client/services/webrtc/media_source.py +120 -0
  23. phystack_hub_client-6.18.1/src/phystack/hub_client/services/webrtc/media_stream.py +1121 -0
  24. {phystack_hub_client-0.2.4 → phystack_hub_client-6.18.1}/src/phystack/hub_client/twin_messaging.py +55 -1
  25. {phystack_hub_client-0.2.4 → phystack_hub_client-6.18.1}/src/phystack/hub_client/twin_registry.py +16 -0
  26. {phystack_hub_client-0.2.4 → phystack_hub_client-6.18.1}/src/phystack/hub_client/types/twin_types.py +43 -0
  27. phystack_hub_client-6.18.1/tests/test_advisory_validation.py +260 -0
  28. phystack_hub_client-6.18.1/tests/test_client_integration.py +114 -0
  29. phystack_hub_client-6.18.1/tests/test_cloud_app_token.py +594 -0
  30. phystack_hub_client-6.18.1/tests/test_cloud_twins.py +249 -0
  31. phystack_hub_client-6.18.1/tests/test_data_channel.py +354 -0
  32. phystack_hub_client-6.18.1/tests/test_instance_listeners.py +118 -0
  33. phystack_hub_client-6.18.1/tests/test_media_kinds.py +231 -0
  34. phystack_hub_client-6.18.1/tests/test_media_resilience.py +511 -0
  35. phystack_hub_client-6.18.1/tests/test_media_responder.py +318 -0
  36. phystack_hub_client-6.18.1/tests/test_media_source.py +86 -0
  37. phystack_hub_client-6.18.1/tests/test_no_google_stun.py +77 -0
  38. phystack_hub_client-6.18.1/tests/test_peripheral_lifecycle.py +451 -0
  39. phystack_hub_client-6.18.1/tests/test_signals_rollover.py +213 -0
  40. phystack_hub_client-6.18.1/tests/test_web_session_code.py +354 -0
  41. phystack_hub_client-0.2.4/src/phystack/hub_client/client.py +0 -799
  42. phystack_hub_client-0.2.4/src/phystack/hub_client/instances/edge_instance.py +0 -216
  43. phystack_hub_client-0.2.4/src/phystack/hub_client/instances/peripheral_instance.py +0 -190
  44. phystack_hub_client-0.2.4/src/phystack/hub_client/services/webrtc/data_channel.py +0 -570
  45. phystack_hub_client-0.2.4/src/phystack/hub_client/services/webrtc/media_stream.py +0 -614
  46. {phystack_hub_client-0.2.4 → phystack_hub_client-6.18.1}/.gitignore +0 -0
  47. {phystack_hub_client-0.2.4 → phystack_hub_client-6.18.1}/examples/browser_test_backend.py +0 -0
  48. {phystack_hub_client-0.2.4 → phystack_hub_client-6.18.1}/scripts/python-node-test.sh +0 -0
  49. {phystack_hub_client-0.2.4 → phystack_hub_client-6.18.1}/scripts/webrtc-test.sh +0 -0
  50. {phystack_hub_client-0.2.4 → phystack_hub_client-6.18.1}/src/phystack/hub_client/instances/__init__.py +0 -0
  51. {phystack_hub_client-0.2.4 → phystack_hub_client-6.18.1}/src/phystack/hub_client/services/__init__.py +0 -0
  52. {phystack_hub_client-0.2.4 → phystack_hub_client-6.18.1}/src/phystack/hub_client/types/__init__.py +0 -0
  53. {phystack_hub_client-0.2.4 → phystack_hub_client-6.18.1}/tests/test_communication.py +0 -0
@@ -0,0 +1,65 @@
1
+ # Changelog — phystack-hub-client (Python)
2
+
3
+ Fixed version group with npm `@phystack/hub-client` (phystack/ps-platform): same
4
+ twin/WebRTC contract, released under the same version. The PyPI workflow only
5
+ publishes even minors from `main`, so a Python release is the next even minor at
6
+ or above the npm version it matches; the entries below name the npm version and
7
+ PRs each change was ported from.
8
+
9
+ ## 6.18.0 — parity with npm 6.16.0 (TECH-1521)
10
+
11
+ ### Changed
12
+
13
+ - Removed the hardcoded Google STUN fallback (npm 6.16.0, ps-platform #973,
14
+ TECH-1516/TECH-1521). `MediaStreamOptions.stun_servers` and
15
+ `DataChannelOptions.stun_servers` now default to the `PHYSTACK_STUN_URL` env
16
+ var when set, otherwise an empty list — with no `ice_servers_provider`
17
+ resolving, connections degrade to host candidates only instead of contacting
18
+ `stun.l.google.com`. All STUN normally comes from the phyhub-minted ICE list
19
+ (`stun:turn-<region>.phystack.com:3478`).
20
+ - Every configuration builder now returns an explicit `RTCConfiguration`
21
+ (possibly with an empty server list) instead of `None`: aiortc substitutes
22
+ its own default — Google STUN — when a peer connection is created without a
23
+ configuration, which would have silently reintroduced the third-party STUN.
24
+ - Guard tests: defaults carry no third-party STUN, and a source scan fails the
25
+ suite if a Google STUN host/port literal ever reappears in the package.
26
+
27
+ ## 6.16.0 — parity with npm 6.15.1 (TECH-1483)
28
+
29
+ ### Added
30
+
31
+ - `PhyHubClient.create_web_session_code(twin_id=… | endpoint_id=…)` and
32
+ `PhyHubClient.subscribe_web_session_code(listener, …)`: the device-side
33
+ web-session claim-code issuer (npm 6.10.0 / 6.11.0, ps-platform #893, #896).
34
+ Same wire event (`createWebSessionCode`) and ack contract as npm; the
35
+ rotating issuer renews at a fraction of the code TTL with downward jitter,
36
+ re-mints after a socket reconnect, retries failures with capped exponential
37
+ backoff, and reports `unavailable` states (terminal for a non-Web twin).
38
+ Device sessions only, as in npm (`services/web_session_code.py`).
39
+
40
+ ### Parity audit — device-session semantics vs npm 6.14.0 / 6.15.0 / 6.15.1
41
+
42
+ npm 6.9.0–6.15.x is mostly web-session work (browser sessions behind a web
43
+ endpoint), which has no Python counterpart and is not needed for edge apps.
44
+ Verified, no code change required:
45
+
46
+ - Twin messages from a device are sent fire-and-forget (`_send_event` →
47
+ `twinMessage` without an ack callback; requests settle on the `response` twin
48
+ message or the 10 s timeout). npm 6.15.0 added ack-based fail-fast and
49
+ 6.15.1 (ps-platform #927) reverted it for device sessions because the
50
+ edge-hub relays acks that do not describe delivery. Python never had it.
51
+ - Consumer media dials use the client's own identity
52
+ (`PhyHubClient.create_media_stream(twin_id)`); the responder replies to the
53
+ offer's `sourceTwinId`, so this matches the 6.15.1 device behaviour.
54
+ - Owner-only peripheral methods (`update_reported`, `update_desired`,
55
+ `remove`) are not gated client-side; the server decides (npm 6.15.1).
56
+ - `disconnect()` leaves the client reusable (npm 6.14.0 fixed a cached dead
57
+ client on the Node side; Python re-connects through `PhyHubConnection`).
58
+
59
+ Not ported (web-session only): `connect({ webApp })`, `getWebInstance()`,
60
+ session persistence/resume, web `getPeripheralInstance()`, the `media:view`
61
+ signaling gate (phyhub-side).
62
+
63
+ ## 6.8.0 — parity with npm 6.8.0 (TECH-1417)
64
+
65
+ See the release notes on GitHub.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: phystack-hub-client
3
- Version: 0.2.4
3
+ Version: 6.18.1
4
4
  Summary: Python client for PhyHub - events, actions, DataChannel, and MediaStream
5
5
  Project-URL: Homepage, https://github.com/phystack/phystack-pypi
6
6
  Project-URL: Documentation, https://docs.phystack.com
@@ -15,11 +15,12 @@ Classifier: Programming Language :: Python :: 3.10
15
15
  Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
17
  Requires-Python: >=3.10
18
- Requires-Dist: aiohttp>=3.9.0
19
- Requires-Dist: aiortc>=1.6.0
20
- Requires-Dist: numpy>=1.24.0
21
- Requires-Dist: opencv-python>=4.8.0
22
- Requires-Dist: python-socketio[asyncio-client]>=5.10.0
18
+ Requires-Dist: aiohttp<4.0.0,>=3.9.0
19
+ Requires-Dist: aiortc<2.0.0,>=1.6.0
20
+ Requires-Dist: numpy<3.0.0,>=1.24.0
21
+ Requires-Dist: opencv-python<5.0.0,>=4.8.0
22
+ Requires-Dist: python-engineio<4.12.0,>=4.9.0
23
+ Requires-Dist: python-socketio[asyncio-client]<5.12.0,>=5.11.0
23
24
  Provides-Extra: dev
24
25
  Requires-Dist: black>=23.0.0; extra == 'dev'
25
26
  Requires-Dist: mypy>=1.5.0; extra == 'dev'
@@ -102,6 +103,35 @@ def handle_action(data, respond):
102
103
  instance.on("my-action", handle_action)
103
104
  ```
104
105
 
106
+ ### Web-session claim codes (QR for web apps)
107
+
108
+ A screen/edge app can mint the claim codes a phone scans to open a web app
109
+ session (the device side of the web-app QR flow). Identify the Web endpoint by
110
+ exactly one of `twin_id` (the Web twin's `_id`, what a settings twin picker
111
+ stores) or `endpoint_id` (the Web twin's `deviceId`, as shown by the Console
112
+ and `phy web-endpoint get`).
113
+
114
+ ```python
115
+ # One code — the URL is the complete link to render as a QR
116
+ issued = await client.create_web_session_code(twin_id=settings["remoteWeb"]["id"])
117
+ print(issued.url, issued.expires_in)
118
+
119
+ # Rotating codes for a permanently displayed QR: the listener gets the initial
120
+ # code, every rotation, a re-mint after each reconnect, and `unavailable`
121
+ # states while minting fails (hide the QR instead of showing a dead code).
122
+ def render(state):
123
+ if state.status == "active":
124
+ show_qr(state.url)
125
+ else:
126
+ hide_qr(state.message)
127
+
128
+ subscription = client.subscribe_web_session_code(render, endpoint_id="863d9307-…")
129
+ subscription.refresh() # mint now
130
+ subscription.stop() # release timers and the reconnect listener
131
+ ```
132
+
133
+ Device sessions only: phyhub registers `createWebSessionCode` on device sockets, so a cloud-app session raises immediately.
134
+
105
135
  ### WebRTC DataChannel
106
136
 
107
137
  ```python
@@ -72,6 +72,35 @@ def handle_action(data, respond):
72
72
  instance.on("my-action", handle_action)
73
73
  ```
74
74
 
75
+ ### Web-session claim codes (QR for web apps)
76
+
77
+ A screen/edge app can mint the claim codes a phone scans to open a web app
78
+ session (the device side of the web-app QR flow). Identify the Web endpoint by
79
+ exactly one of `twin_id` (the Web twin's `_id`, what a settings twin picker
80
+ stores) or `endpoint_id` (the Web twin's `deviceId`, as shown by the Console
81
+ and `phy web-endpoint get`).
82
+
83
+ ```python
84
+ # One code — the URL is the complete link to render as a QR
85
+ issued = await client.create_web_session_code(twin_id=settings["remoteWeb"]["id"])
86
+ print(issued.url, issued.expires_in)
87
+
88
+ # Rotating codes for a permanently displayed QR: the listener gets the initial
89
+ # code, every rotation, a re-mint after each reconnect, and `unavailable`
90
+ # states while minting fails (hide the QR instead of showing a dead code).
91
+ def render(state):
92
+ if state.status == "active":
93
+ show_qr(state.url)
94
+ else:
95
+ hide_qr(state.message)
96
+
97
+ subscription = client.subscribe_web_session_code(render, endpoint_id="863d9307-…")
98
+ subscription.refresh() # mint now
99
+ subscription.stop() # release timers and the reconnect listener
100
+ ```
101
+
102
+ Device sessions only: phyhub registers `createWebSessionCode` on device sockets, so a cloud-app session raises immediately.
103
+
75
104
  ### WebRTC DataChannel
76
105
 
77
106
  ```python
@@ -4,7 +4,10 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "phystack-hub-client"
7
- version = "0.2.4"
7
+ # Fixed version group with the Node hub-client (@phystack/hub-client in
8
+ # phystack/ps-platform): both clients implement the same twin/WebRTC contract
9
+ # and are released together under the same version.
10
+ version = "6.18.1"
8
11
  description = "Python client for PhyHub - events, actions, DataChannel, and MediaStream"
9
12
  readme = "README.md"
10
13
  license = "MIT"
@@ -22,11 +25,22 @@ classifiers = [
22
25
  "Programming Language :: Python :: 3.12",
23
26
  ]
24
27
  dependencies = [
25
- "python-socketio[asyncio_client]>=5.10.0",
26
- "aiohttp>=3.9.0",
27
- "aiortc>=1.6.0",
28
- "opencv-python>=4.8.0",
29
- "numpy>=1.24.0",
28
+ # Pinned to narrow ranges tested against the PhyOS hub socket.io server
29
+ # (socket.io 4.8.1 / engine.io 6.6.4). python-engineio 4.12+ has a bug
30
+ # in the WebSocket upgrade error path: _connect_websocket can pass a
31
+ # WebSocketError object into the packet decoder, causing
32
+ # "TypeError: object of type 'WebSocketError' has no len()".
33
+ "python-socketio[asyncio_client]>=5.11.0,<5.12.0",
34
+ "python-engineio>=4.9.0,<4.12.0",
35
+ # aiohttp 4.x is an announced future breaking release; cap below it.
36
+ "aiohttp>=3.9.0,<4.0.0",
37
+ # aiortc has had minor-version API breaks; cap at next major.
38
+ "aiortc>=1.6.0,<2.0.0",
39
+ # opencv-python 5.x is not released yet; cap at next major.
40
+ "opencv-python>=4.8.0,<5.0.0",
41
+ # numpy 2.0 was a large ABI change. Allow 2.x but cap before the next
42
+ # unreleased major to avoid surprise breaks.
43
+ "numpy>=1.24.0,<3.0.0",
30
44
  ]
31
45
 
32
46
  [project.optional-dependencies]
@@ -10,6 +10,12 @@ A Python client for PhyHub that provides:
10
10
  - Settings API
11
11
 
12
12
  Usage:
13
+ from phystack.hub_client import connect_phy_client
14
+
15
+ client = await connect_phy_client()
16
+ instance = await client.get_instance()
17
+
18
+ Or with context manager:
13
19
  from phystack.hub_client import PhyHubClient
14
20
 
15
21
  async with PhyHubClient.from_env() as client:
@@ -38,14 +44,22 @@ Usage:
38
44
  stream = await instance.get_media_stream(peer_twin_id)
39
45
  """
40
46
 
41
- from phystack.hub_client.client import PhyHubClient
47
+ from phystack.hub_client.client import PhyHubClient, connect_phy_client
42
48
  from phystack.hub_client.types.twin_types import (
49
+ PeripheralCreateResult,
50
+ PeripheralHandover,
51
+ PeripheralOwnershipLoss,
43
52
  TwinMessageResult,
44
53
  TwinMessageResultStatus,
45
54
  TwinProperties,
46
55
  TwinTypeEnum,
47
56
  TwinStatusEnum,
48
57
  )
58
+ from phystack.hub_client.scoped_hardware_id import (
59
+ HARDWARE_ID_SCOPES,
60
+ HARDWARE_ID_USER_PART_MAX_LENGTH,
61
+ format_scoped_hardware_id,
62
+ )
49
63
  from phystack.hub_client.instances import (
50
64
  BaseInstance,
51
65
  EdgeInstance,
@@ -60,9 +74,49 @@ from phystack.hub_client.services.webrtc.data_channel import (
60
74
  from phystack.hub_client.services.webrtc.media_stream import (
61
75
  MediaStreamHandler,
62
76
  MediaStreamOptions,
77
+ MediaTrackKind,
78
+ MissingMediaTrackError,
63
79
  PhygridMediaStream,
64
80
  WebcamVideoTrack,
65
81
  )
82
+ from phystack.hub_client.services.webrtc.media_source import (
83
+ FrameSourceTrack,
84
+ VideoFrameSource,
85
+ )
86
+ from phystack.hub_client.services.webrtc.media_responder import (
87
+ MediaStreamResponder,
88
+ MediaStreamResponderOptions,
89
+ PeerCapReachedError,
90
+ ResponderPeer,
91
+ )
92
+ from phystack.hub_client.services.webrtc.data_responder import (
93
+ DataChannelResponder,
94
+ DataChannelResponderOptions,
95
+ ResponderChannel,
96
+ )
97
+ from phystack.hub_client.advisory_validation import (
98
+ CompiledValidator,
99
+ DescriptorValidator,
100
+ validate_emitted_facet,
101
+ )
102
+ from phystack.hub_client.services.cloud_app_token import (
103
+ CloudAppToken,
104
+ CloudAppTokenExchangeError,
105
+ exchange_cloud_app_token,
106
+ )
107
+ from phystack.hub_client.services.cloud_app_connection import (
108
+ CloudAppConnection,
109
+ CloudAppConnectionParams,
110
+ CloudAppConnectResult,
111
+ )
112
+ from phystack.hub_client.cloud_twins import CloudTwinStore
113
+ from phystack.hub_client.services.web_session_code import (
114
+ WebSessionCode,
115
+ WebSessionCodeConfigError,
116
+ WebSessionCodeIssuer,
117
+ WebSessionCodeListener,
118
+ WebSessionCodeState,
119
+ )
66
120
  from phystack.hub_client.services.signals import (
67
121
  SignalsService,
68
122
  InitSignalPayload,
@@ -74,10 +128,13 @@ from phystack.hub_client.services.signals import (
74
128
  PurchaseProps,
75
129
  )
76
130
 
77
- __version__ = "0.1.0"
131
+ # Kept in a fixed version group with @phystack/hub-client (npm) — same
132
+ # contract, same release version.
133
+ __version__ = "6.16.0"
78
134
  __all__ = [
79
135
  # Client
80
136
  "PhyHubClient",
137
+ "connect_phy_client",
81
138
  # Instances
82
139
  "BaseInstance",
83
140
  "EdgeInstance",
@@ -89,14 +146,50 @@ __all__ = [
89
146
  "TwinProperties",
90
147
  "TwinTypeEnum",
91
148
  "TwinStatusEnum",
149
+ # Peripheral identity + ownership (TECH-1394)
150
+ "PeripheralCreateResult",
151
+ "PeripheralHandover",
152
+ "PeripheralOwnershipLoss",
153
+ "HARDWARE_ID_SCOPES",
154
+ "HARDWARE_ID_USER_PART_MAX_LENGTH",
155
+ "format_scoped_hardware_id",
92
156
  # WebRTC
93
157
  "DataChannelHandler",
94
158
  "DataChannelOptions",
95
159
  "PhygridDataChannel",
96
160
  "MediaStreamHandler",
97
161
  "MediaStreamOptions",
162
+ "MediaTrackKind",
163
+ "MissingMediaTrackError",
98
164
  "PhygridMediaStream",
99
165
  "WebcamVideoTrack",
166
+ "FrameSourceTrack",
167
+ "VideoFrameSource",
168
+ "MediaStreamResponder",
169
+ "MediaStreamResponderOptions",
170
+ "PeerCapReachedError",
171
+ "ResponderPeer",
172
+ "DataChannelResponder",
173
+ "DataChannelResponderOptions",
174
+ "ResponderChannel",
175
+ # Advisory validation
176
+ "CompiledValidator",
177
+ "DescriptorValidator",
178
+ "validate_emitted_facet",
179
+ # Cloud-app mode
180
+ "CloudAppToken",
181
+ "CloudAppTokenExchangeError",
182
+ "exchange_cloud_app_token",
183
+ "CloudAppConnection",
184
+ "CloudAppConnectionParams",
185
+ "CloudAppConnectResult",
186
+ "CloudTwinStore",
187
+ # Web-session claim codes (device-side QR issuer)
188
+ "WebSessionCode",
189
+ "WebSessionCodeConfigError",
190
+ "WebSessionCodeIssuer",
191
+ "WebSessionCodeListener",
192
+ "WebSessionCodeState",
100
193
  # Signals
101
194
  "SignalsService",
102
195
  "InitSignalPayload",
@@ -0,0 +1,114 @@
1
+ """Advisory descriptor validation (opt-in, on-device).
2
+
3
+ Python port of the npm hub-client ``advisory-validation.ts`` module. Validates
4
+ the facets an edge app EMITS (reported properties, event payloads, action
5
+ returns) against precompiled validators the app supplies from its own compiled
6
+ descriptor schema (the build-time-embed approach).
7
+
8
+ Hard rules this module enforces (npm parity):
9
+
10
+ - **Opt-in.** No validator supplied -> no validation; behavior is identical to
11
+ a non-validating client.
12
+ - **Advisory + routing-independent.** On a mismatch a warning is logged and the
13
+ caller STILL emits/sends/stores. The validation outcome never affects whether
14
+ or how a message goes on the wire.
15
+ - **Fail-open.** Missing validator for a facet -> skip; any error raised inside
16
+ the validation path -> caught, logged, and skipped. This module never raises.
17
+
18
+ Like the npm package (which deliberately does not depend on Ajv), this module
19
+ carries no JSON-schema runtime. The opt-in contract is a set of precompiled
20
+ validate callables: ``Callable[[Any], bool]`` that may expose the last run's
21
+ errors on an ``errors`` attribute (duck-typed, mirroring Ajv's compiled shape).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from dataclasses import dataclass, field
28
+ from typing import Any, Callable, Optional
29
+
30
+ # A precompiled validate function: returns True/False for a payload and may
31
+ # expose the last run's errors on an `errors` attribute (duck-typed).
32
+ CompiledValidator = Callable[[Any], bool]
33
+
34
+ # Facet names mirror the npm module: 'reported' | 'event' | 'actionReturns'.
35
+ FACET_REPORTED = "reported"
36
+ FACET_EVENT = "event"
37
+ FACET_ACTION_RETURNS = "actionReturns"
38
+
39
+ _default_logger = logging.getLogger("phystack.hub_client.advisory_validation")
40
+
41
+
42
+ @dataclass
43
+ class DescriptorValidator:
44
+ """Per-facet precompiled validators supplied by the edge app.
45
+
46
+ Every field is optional: a facet with no validator is simply not checked
47
+ (fail-open). Event validators are keyed by event type; action-return
48
+ validators by action name.
49
+ """
50
+
51
+ reported: Optional[CompiledValidator] = None
52
+ events: dict[str, CompiledValidator] = field(default_factory=dict)
53
+ action_returns: dict[str, CompiledValidator] = field(default_factory=dict)
54
+
55
+
56
+ def _describe(descriptor_label: str, facet: str, facet_key: Optional[str]) -> str:
57
+ facet_part = f"{facet} '{facet_key}'" if facet_key else facet
58
+ return f"{facet_part} on '{descriptor_label}'"
59
+
60
+
61
+ def validate_emitted_facet(
62
+ validator: Optional[CompiledValidator],
63
+ data: Any,
64
+ descriptor_label: str,
65
+ facet: str,
66
+ facet_key: Optional[str] = None,
67
+ logger: Optional[logging.Logger] = None,
68
+ ) -> bool:
69
+ """Run a single precompiled validator over ``data``, advisory + fail-open.
70
+
71
+ NEVER raises and NEVER signals "block this message" — the return value is
72
+ purely informational (True = conformed/skipped, False = a mismatch was
73
+ logged) and callers MUST emit regardless of it. A ``validator`` of ``None``
74
+ means the app didn't supply one for this facet -> skip (fail-open). Any
75
+ error raised by the validator itself is caught, logged, and treated as a
76
+ skip (fail-open) so a broken schema can never block telemetry.
77
+
78
+ Args:
79
+ validator: Precompiled validate callable, or None to skip.
80
+ data: The emitted payload to check.
81
+ descriptor_label: Identifies the descriptor/peripheral in the warning.
82
+ facet: Which emitted facet this is ('reported'/'event'/'actionReturns').
83
+ facet_key: Event type or action name, when applicable.
84
+ logger: Logger for warnings; defaults to the module logger.
85
+
86
+ Returns:
87
+ True if the data conformed or validation was skipped, False on mismatch.
88
+ """
89
+ if validator is None:
90
+ # Opt-out / no schema for this facet -> skip, behave like a
91
+ # non-validating client.
92
+ return True
93
+
94
+ active_logger = logger if logger is not None else _default_logger
95
+
96
+ try:
97
+ conforms = bool(validator(data))
98
+ except Exception as error: # noqa: BLE001 - fail-open by contract
99
+ # Fail-open: a broken validator must never block an emit.
100
+ active_logger.warning(
101
+ "Failed to run advisory descriptor validation for %s — skipping (fail-open): %r",
102
+ _describe(descriptor_label, facet, facet_key),
103
+ error,
104
+ )
105
+ return True
106
+
107
+ if not conforms:
108
+ active_logger.warning(
109
+ "Advisory descriptor validation mismatch for %s — sending anyway (advisory): %r",
110
+ _describe(descriptor_label, facet, facet_key),
111
+ getattr(validator, "errors", None),
112
+ )
113
+
114
+ return conforms