quelware-client 0.2.0__tar.gz → 0.3.0__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 (68) hide show
  1. {quelware_client-0.2.0 → quelware_client-0.3.0}/CHANGELOG.md +17 -0
  2. {quelware_client-0.2.0 → quelware_client-0.3.0}/PKG-INFO +2 -2
  3. {quelware_client-0.2.0 → quelware_client-0.3.0}/pyproject.toml +2 -2
  4. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/client/_grpc.py +18 -0
  5. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/client/_standalone_grpc.py +7 -0
  6. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/core/_agent_container.py +28 -0
  7. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/core/_client.py +5 -0
  8. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/core/_session.py +48 -22
  9. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/core/exceptions/__init__.py +4 -0
  10. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/core/interfaces/instrument_agent/__init__.py +0 -5
  11. quelware_client-0.3.0/src/quelware_client/core/interfaces/trigger_agent/__init__.py +18 -0
  12. quelware_client-0.3.0/src/quelware_client/core/interfaces/worker_agent/__init__.py +7 -0
  13. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/infra/instrument_agent_grpc/__init__.py +0 -11
  14. quelware_client-0.3.0/src/quelware_client/infra/trigger_agent_grpc/__init__.py +52 -0
  15. quelware_client-0.3.0/src/quelware_client/infra/worker_agent_grpc/__init__.py +24 -0
  16. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/testing/instrument_agent_mock/__init__.py +0 -6
  17. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/testing/mock_client.py +7 -0
  18. quelware_client-0.3.0/src/quelware_client/testing/trigger_agent_mock/__init__.py +24 -0
  19. quelware_client-0.3.0/src/quelware_client/testing/worker_agent_mock/__init__.py +12 -0
  20. quelware_client-0.3.0/tests/core/session/test_trigger.py +38 -0
  21. {quelware_client-0.2.0 → quelware_client-0.3.0}/uv.lock +2 -2
  22. {quelware_client-0.2.0 → quelware_client-0.3.0}/.gitignore +0 -0
  23. {quelware_client-0.2.0 → quelware_client-0.3.0}/LICENSE +0 -0
  24. {quelware_client-0.2.0 → quelware_client-0.3.0}/Makefile +0 -0
  25. {quelware_client-0.2.0 → quelware_client-0.3.0}/README.md +0 -0
  26. {quelware_client-0.2.0 → quelware_client-0.3.0}/examples/generate_readout_pulse.py +0 -0
  27. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/__init__.py +0 -0
  28. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/cli/__init__.py +0 -0
  29. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/cli/quel3_echo_test.py +0 -0
  30. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/client/__init__.py +0 -0
  31. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/client/helpers/__init__.py +0 -0
  32. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/client/helpers/instrument_resolver/__init__.py +0 -0
  33. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/client/helpers/sequencer/__init__.py +0 -0
  34. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/core/__init__.py +0 -0
  35. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/core/_utils.py +0 -0
  36. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/core/instrument_driver/__init__.py +0 -0
  37. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/core/interfaces/__init__.py +0 -0
  38. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/core/interfaces/diagnostics_agent/__init__.py +0 -0
  39. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/core/interfaces/health_agent/__init__.py +0 -0
  40. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/core/interfaces/pat_provider/__init__.py +0 -0
  41. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/core/interfaces/resource_agent/__init__.py +0 -0
  42. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/core/interfaces/session_agent/__init__.py +0 -0
  43. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/core/interfaces/system_configuration_agent/__init__.py +0 -0
  44. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/core/trigger_count_proposer/__init__.py +0 -0
  45. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/infra/__init__.py +0 -0
  46. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/infra/_grpc_retry.py +0 -0
  47. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/infra/diagnostics_agent_grpc/__init__.py +0 -0
  48. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/infra/health_agent_grpc/__init__.py +0 -0
  49. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/infra/pat_provider_file/__init__.py +0 -0
  50. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/infra/resource_agent_grpc/__init__.py +0 -0
  51. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/infra/session_agent_grpc/__init__.py +0 -0
  52. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/infra/system_configuration_agent_grpc/__init__.py +0 -0
  53. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/py.typed +0 -0
  54. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/testing/__init__.py +0 -0
  55. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/testing/resource_agent_mock/__init__.py +0 -0
  56. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/testing/session_agent_mock/__init__.py +0 -0
  57. {quelware_client-0.2.0 → quelware_client-0.3.0}/src/quelware_client/testing/system_configuration_agent_mock/__init__.py +0 -0
  58. {quelware_client-0.2.0 → quelware_client-0.3.0}/tests/__init__.py +0 -0
  59. {quelware_client-0.2.0 → quelware_client-0.3.0}/tests/core/__init__.py +0 -0
  60. {quelware_client-0.2.0 → quelware_client-0.3.0}/tests/core/client/__init__.py +0 -0
  61. {quelware_client-0.2.0 → quelware_client-0.3.0}/tests/core/client/helpers/__init__.py +0 -0
  62. {quelware_client-0.2.0 → quelware_client-0.3.0}/tests/core/client/helpers/test_instrument_resolver.py +0 -0
  63. {quelware_client-0.2.0 → quelware_client-0.3.0}/tests/core/client/helpers/test_sequencer.py +0 -0
  64. {quelware_client-0.2.0 → quelware_client-0.3.0}/tests/core/client/test_client.py +0 -0
  65. {quelware_client-0.2.0 → quelware_client-0.3.0}/tests/core/instrument_driver/__init__.py +0 -0
  66. {quelware_client-0.2.0 → quelware_client-0.3.0}/tests/core/instrument_driver/test_instrument_driver.py +0 -0
  67. {quelware_client-0.2.0 → quelware_client-0.3.0}/tests/core/session/__init__.py +0 -0
  68. {quelware_client-0.2.0 → quelware_client-0.3.0}/tests/core/session/test_session.py +0 -0
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.3.0] - 2026-06-09
6
+
7
+ ### Added
8
+
9
+ - `Session.trigger` now routes through the manager by default (single round-trip, shorter lead).
10
+ - `ServiceUnavailableError` exception for catching cases where the server-side service is not available.
11
+
12
+ ### Changed
13
+
14
+ - `Session.trigger` default `wait_ms` changed from `400` to `None`.
15
+ - `Session.trigger` returns the scheduled clock count (`int`).
16
+ - Default multi-unit trigger offset is 0 ticks (was 16).
17
+
18
+ ### Removed
19
+
20
+ - `agent.instrument(unit).get_clock_snapshot()` — use `agent.worker(unit).get_clock_snapshot()`.
21
+
5
22
  ## [0.2.0] - 2026-05-28
6
23
 
7
24
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quelware-client
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Project-URL: Repository, https://github.com/quel-inc/quelware-client
5
5
  Project-URL: Documentation, https://quel-inc.github.io/quelware-client/
6
6
  Project-URL: Changelog, https://github.com/quel-inc/quelware-client/blob/main/quelware-client/CHANGELOG.md
@@ -10,7 +10,7 @@ License-File: LICENSE
10
10
  Requires-Python: >=3.10
11
11
  Requires-Dist: betterproto2>=0.9.1
12
12
  Requires-Dist: grpclib>=0.4.8
13
- Requires-Dist: quelware-core>=0.1.4
13
+ Requires-Dist: quelware-core>=0.2.0
14
14
  Requires-Dist: typer>=0.21.1
15
15
  Requires-Dist: typing-extensions>=4.1.0
16
16
  Provides-Extra: plot
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quelware-client"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  authors = [
@@ -9,7 +9,7 @@ authors = [
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
11
11
  "betterproto2>=0.9.1",
12
- "quelware-core>=0.1.4",
12
+ "quelware-core>=0.2.0",
13
13
  "grpclib>=0.4.8",
14
14
  "typer>=0.21.1",
15
15
  "typing_extensions>=4.1.0"
@@ -13,6 +13,7 @@ from quelware_client.core.interfaces.session_agent import SessionAgent
13
13
  from quelware_client.core.interfaces.system_configuration_agent import (
14
14
  SystemConfigurationAgent,
15
15
  )
16
+ from quelware_client.core.interfaces.worker_agent import WorkerAgent
16
17
  from quelware_client.infra.diagnostics_agent_grpc import DiagnosticsAgentGrpc
17
18
  from quelware_client.infra.health_agent_grpc import HealthAgentGrpc
18
19
  from quelware_client.infra.instrument_agent_grpc import InstrumentAgentGrpc
@@ -24,6 +25,8 @@ from quelware_client.infra.session_agent_grpc import SessionAgentGrpc
24
25
  from quelware_client.infra.system_configuration_agent_grpc import (
25
26
  SystemConfigurationAgentGrpc,
26
27
  )
28
+ from quelware_client.infra.trigger_agent_grpc import TriggerAgentGrpc
29
+ from quelware_client.infra.worker_agent_grpc import WorkerAgentGrpc
27
30
 
28
31
  logger = logging.getLogger(__name__)
29
32
 
@@ -64,6 +67,13 @@ def _create_default_diagnostics_agent_factory(channel, pat: str):
64
67
  return _default_diagnostics_agent_factory
65
68
 
66
69
 
70
+ def _create_default_worker_agent_factory(channel):
71
+ def _default_worker_agent_factory(ul: UnitLabel):
72
+ return WorkerAgentGrpc(channel, metadata={"x-unit-label": str(ul)})
73
+
74
+ return _default_worker_agent_factory
75
+
76
+
67
77
  _CENTRAL_SERVER_METADATA_BASE = {"x-unit-label": "central-server"}
68
78
 
69
79
 
@@ -76,6 +86,7 @@ def create_quelware_client( # noqa: PLR0913
76
86
  resource_agent_factory: AgentFactory[ResourceAgent] | None = None,
77
87
  instrument_agent_factory: AgentFactory[InstrumentAgent] | None = None,
78
88
  diagnostics_agent_factory: AgentFactory[DiagnosticsAgent] | None = None,
89
+ worker_agent_factory: AgentFactory[WorkerAgent] | None = None,
79
90
  pat: PatProvider | str | None = None,
80
91
  ):
81
92
  channel = Channel(endpoint, port)
@@ -105,6 +116,9 @@ def create_quelware_client( # noqa: PLR0913
105
116
  channel, _pat
106
117
  )
107
118
 
119
+ if worker_agent_factory is None:
120
+ worker_agent_factory = _create_default_worker_agent_factory(channel)
121
+
108
122
  central_server_metadata = _CENTRAL_SERVER_METADATA_BASE | {"x-pat": _pat}
109
123
 
110
124
  agent_container = AgentContainer()
@@ -116,11 +130,15 @@ def create_quelware_client( # noqa: PLR0913
116
130
  agent_container.system_configuration = SystemConfigurationAgentGrpc(
117
131
  channel, metadata=central_server_metadata
118
132
  )
133
+ agent_container.trigger = TriggerAgentGrpc(
134
+ channel, metadata=central_server_metadata
135
+ )
119
136
  return QuelwareClient(
120
137
  agent=agent_container,
121
138
  health_agent_factory=health_agent_factory,
122
139
  resource_agent_factory=resource_agent_factory,
123
140
  instrument_agent_factory=instrument_agent_factory,
124
141
  diagnostics_agent_factory=diagnostics_agent_factory,
142
+ worker_agent_factory=worker_agent_factory,
125
143
  close_handlers=[channel.close],
126
144
  )
@@ -9,6 +9,8 @@ from quelware_client.infra.diagnostics_agent_grpc import DiagnosticsAgentGrpc
9
9
  from quelware_client.infra.instrument_agent_grpc import InstrumentAgentGrpc
10
10
  from quelware_client.infra.pat_provider_file import pat_provider_from_config
11
11
  from quelware_client.infra.resource_agent_grpc import ResourceAgentGrpc
12
+ from quelware_client.infra.trigger_agent_grpc import TriggerAgentGrpc
13
+ from quelware_client.infra.worker_agent_grpc import WorkerAgentGrpc
12
14
  from quelware_client.testing.session_agent_mock import SessionAgentMock
13
15
  from quelware_client.testing.system_configuration_agent_mock import (
14
16
  SystemConfigurationAgentMock,
@@ -59,9 +61,13 @@ def create_standalone_client(
59
61
  channel, metadata={"x-unit-label": str(ul), "x-pat": _pat}
60
62
  )
61
63
 
64
+ def worker_agent_factory(ul: UnitLabel):
65
+ return WorkerAgentGrpc(channel, metadata={"x-unit-label": str(ul)})
66
+
62
67
  agent_container = AgentContainer()
63
68
  agent_container.session = session_agent
64
69
  agent_container.system_configuration = conf_agent
70
+ agent_container.trigger = TriggerAgentGrpc(channel)
65
71
 
66
72
  logger.warning("NOTE: Standalone client is for testing purposes.")
67
73
 
@@ -70,6 +76,7 @@ def create_standalone_client(
70
76
  resource_agent_factory=resource_agent_factory,
71
77
  instrument_agent_factory=instrument_agent_factory,
72
78
  diagnostics_agent_factory=diagnostics_agent_factory,
79
+ worker_agent_factory=worker_agent_factory,
73
80
  close_handlers=[channel.close],
74
81
  skip_lock_check=skip_lock_check,
75
82
  )
@@ -10,6 +10,8 @@ from quelware_client.core.interfaces.session_agent import SessionAgent
10
10
  from quelware_client.core.interfaces.system_configuration_agent import (
11
11
  SystemConfigurationAgent,
12
12
  )
13
+ from quelware_client.core.interfaces.trigger_agent import TriggerAgent
14
+ from quelware_client.core.interfaces.worker_agent import WorkerAgent
13
15
 
14
16
  logger = logging.getLogger(__name__)
15
17
 
@@ -18,10 +20,12 @@ class AgentContainer:
18
20
  def __init__(self):
19
21
  self._session: SessionAgent | None = None
20
22
  self._conf: SystemConfigurationAgent | None = None
23
+ self._trigger: TriggerAgent | None = None
21
24
  self._health: dict[UnitLabel, HealthAgent] = {}
22
25
  self._rsrc: dict[UnitLabel, ResourceAgent] = {}
23
26
  self._inst: dict[UnitLabel, InstrumentAgent] = {}
24
27
  self._diag: dict[UnitLabel, DiagnosticsAgent] = {}
28
+ self._worker: dict[UnitLabel, WorkerAgent] = {}
25
29
 
26
30
  def update_health_agent(self, unit_label: UnitLabel, agent: HealthAgent | None):
27
31
  if agent is None:
@@ -67,6 +71,15 @@ class AgentContainer:
67
71
  else:
68
72
  self._diag[unit_label] = agent
69
73
 
74
+ def update_worker_agent(self, unit_label: UnitLabel, agent: WorkerAgent | None):
75
+ if agent is None:
76
+ if unit_label in self._worker:
77
+ self._worker[unit_label]
78
+ else:
79
+ logger.warning(f"WorkerAgent for '{unit_label}' not found. Continue.")
80
+ else:
81
+ self._worker[unit_label] = agent
82
+
70
83
  @property
71
84
  def session(self) -> SessionAgent:
72
85
  if self._session is None:
@@ -87,6 +100,16 @@ class AgentContainer:
87
100
  def system_configuration(self, val: SystemConfigurationAgent):
88
101
  self._conf = val
89
102
 
103
+ @property
104
+ def trigger(self) -> TriggerAgent:
105
+ if self._trigger is None:
106
+ raise ValueError("TriggerAgent not set.")
107
+ return self._trigger
108
+
109
+ @trigger.setter
110
+ def trigger(self, val: TriggerAgent):
111
+ self._trigger = val
112
+
90
113
  def health(self, unit_label: UnitLabel) -> HealthAgent:
91
114
  if unit_label not in self._health:
92
115
  raise ValueError(f"HealthAgent for '{unit_label}' not set.")
@@ -106,3 +129,8 @@ class AgentContainer:
106
129
  if unit_label not in self._diag:
107
130
  raise ValueError(f"DiagnosticsAgent for '{unit_label}' not set.")
108
131
  return self._diag[unit_label]
132
+
133
+ def worker(self, unit_label: UnitLabel) -> WorkerAgent:
134
+ if unit_label not in self._worker:
135
+ raise ValueError(f"WorkerAgent for '{unit_label}' not set.")
136
+ return self._worker[unit_label]
@@ -16,6 +16,7 @@ from quelware_client.core._agent_container import AgentContainer
16
16
  from quelware_client.core.interfaces.diagnostics_agent import DiagnosticsAgent
17
17
  from quelware_client.core.interfaces.health_agent import HealthAgent
18
18
  from quelware_client.core.interfaces.instrument_agent import InstrumentAgent
19
+ from quelware_client.core.interfaces.worker_agent import WorkerAgent
19
20
 
20
21
  from ._session import Session
21
22
  from .interfaces.resource_agent import ResourceAgent
@@ -35,6 +36,7 @@ class QuelwareClient:
35
36
  resource_agent_factory: AgentFactory[ResourceAgent] | None = None,
36
37
  instrument_agent_factory: AgentFactory[InstrumentAgent] | None = None,
37
38
  diagnostics_agent_factory: AgentFactory[DiagnosticsAgent] | None = None,
39
+ worker_agent_factory: AgentFactory[WorkerAgent] | None = None,
38
40
  close_handlers: list[Callable[[], None]] | None = None,
39
41
  skip_lock_check: bool = False,
40
42
  ):
@@ -43,6 +45,7 @@ class QuelwareClient:
43
45
  self._rsrc_agent_factory = resource_agent_factory
44
46
  self._inst_agent_factory = instrument_agent_factory
45
47
  self._diag_agent_factory = diagnostics_agent_factory
48
+ self._worker_agent_factory = worker_agent_factory
46
49
  self._unit_labels: list[UnitLabel] = []
47
50
  self._close_handlers = close_handlers or []
48
51
  self._skip_lock_check = skip_lock_check
@@ -91,6 +94,8 @@ class QuelwareClient:
91
94
  self._agent.update_instrument_agent(ul, self._inst_agent_factory(ul))
92
95
  if self._diag_agent_factory:
93
96
  self._agent.update_diagnostics_agent(ul, self._diag_agent_factory(ul))
97
+ if self._worker_agent_factory:
98
+ self._agent.update_worker_agent(ul, self._worker_agent_factory(ul))
94
99
  self._unit_labels = healthy_labels
95
100
 
96
101
  def list_unit_labels(self) -> list[UnitLabel]:
@@ -14,6 +14,7 @@ from quelware_core.entities.session import SessionToken
14
14
  from quelware_core.entities.unit import UnitLabel
15
15
 
16
16
  from quelware_client.core import AgentContainer
17
+ from quelware_client.core.exceptions import ServiceUnavailableError
17
18
  from quelware_client.core.trigger_count_proposer import (
18
19
  FixedOffsetTriggerCountProposer,
19
20
  TriggerCountProposer,
@@ -23,8 +24,9 @@ from ._utils import create_unit_to_ids_map
23
24
 
24
25
  logger = logging.getLogger(__name__)
25
26
 
26
- _default_count_proposer = FixedOffsetTriggerCountProposer(grid_step=32, offset=16)
27
+ _default_count_proposer = FixedOffsetTriggerCountProposer(grid_step=32, offset=0)
27
28
  _CLOCK_FREQUENCY_HZ = 312_500_000
29
+ _FALLBACK_MIN_WAIT_MS = 500
28
30
 
29
31
 
30
32
  class Session:
@@ -143,7 +145,11 @@ class Session:
143
145
  )
144
146
  return insts
145
147
 
146
- async def trigger(self, instrument_ids: Collection[ResourceId], wait_ms=400):
148
+ async def trigger(
149
+ self,
150
+ instrument_ids: Collection[ResourceId],
151
+ wait_ms: int | None = None,
152
+ ) -> int:
147
153
  unit_to_ids = create_unit_to_ids_map(instrument_ids)
148
154
 
149
155
  logger.info(f"starting application (token= {self.token} )")
@@ -154,27 +160,47 @@ class Session:
154
160
  await asyncio.gather(*apply_coros)
155
161
  logger.info(f"finished application (token= {self.token} )")
156
162
 
163
+ try:
164
+ scheduled = await self._agent.trigger.trigger(
165
+ self.token,
166
+ list(instrument_ids),
167
+ requested_min_wait_ms=wait_ms,
168
+ )
169
+ logger.info(f"trigger scheduled via manager at clock_count={scheduled}")
170
+ return scheduled
171
+ except ServiceUnavailableError:
172
+ fallback_wait_ms = max(wait_ms or 0, _FALLBACK_MIN_WAIT_MS)
173
+ logger.warning(
174
+ f"Manager-side TriggerService unavailable; falling back to "
175
+ f"client-side trigger (wait_ms={fallback_wait_ms})."
176
+ )
177
+
178
+ return await self._client_side_trigger_fallback(unit_to_ids, fallback_wait_ms)
179
+
180
+ async def _client_side_trigger_fallback(
181
+ self,
182
+ unit_to_ids: dict[UnitLabel, list[ResourceId]],
183
+ wait_ms: int,
184
+ ) -> int:
157
185
  if len(unit_to_ids) == 1:
158
186
  unit_label = next(iter(unit_to_ids))
159
- logger.info(f"trigger via self-timed path (unit={unit_label})")
187
+ logger.info(f"fallback: self-timed trigger (unit={unit_label})")
160
188
  scheduled = await self._agent.instrument(unit_label).trigger_now(self.token)
161
189
  logger.info(f"trigger scheduled at clock_count={scheduled}")
162
- else:
163
- logger.info(
164
- f"trigger via multi-unit sync path "
165
- f"(n_units={len(unit_to_ids)}, wait_ms={wait_ms})"
166
- )
167
- reference_unit = next(iter(unit_to_ids))
168
- cur, ref = await self._agent.instrument(reference_unit).get_clock_snapshot()
169
- wait_count = math.ceil(wait_ms * _CLOCK_FREQUENCY_HZ / 1000)
170
- target_time = self._trigger_count_proposer.propose_count(
171
- cur, ref, wait_count
172
- )
173
- trigger_coros = [
174
- self._agent.instrument(unit_label).schedule_trigger(
175
- self.token, target_time
176
- )
177
- for unit_label in unit_to_ids
178
- ]
179
- await asyncio.gather(*trigger_coros)
180
- logger.info(f"trigger scheduled at clock_count={target_time}")
190
+ return scheduled
191
+
192
+ logger.info(
193
+ f"fallback: multi-unit sync trigger "
194
+ f"(n_units={len(unit_to_ids)}, wait_ms={wait_ms})"
195
+ )
196
+ reference_unit = next(iter(unit_to_ids))
197
+ cur, ref = await self._agent.worker(reference_unit).get_clock_snapshot()
198
+ wait_count = math.ceil(wait_ms * _CLOCK_FREQUENCY_HZ / 1000)
199
+ target_time = self._trigger_count_proposer.propose_count(cur, ref, wait_count)
200
+ trigger_coros = [
201
+ self._agent.instrument(unit_label).schedule_trigger(self.token, target_time)
202
+ for unit_label in unit_to_ids
203
+ ]
204
+ await asyncio.gather(*trigger_coros)
205
+ logger.info(f"trigger scheduled at clock_count={target_time}")
206
+ return target_time
@@ -72,3 +72,7 @@ class UnitNotFoundError(_ErrorWithUnitLabelsMixin, QuelwareClientError): ...
72
72
 
73
73
 
74
74
  class InvalidUnitStatusError(_ErrorWithUnitLabelsMixin, QuelwareClientError): ...
75
+
76
+
77
+ class ServiceUnavailableError(QuelwareClientError):
78
+ DEFAULT_MESSAGE = "The requested service is not available on the server."
@@ -1,7 +1,6 @@
1
1
  from collections.abc import Collection, Sequence
2
2
  from typing import Protocol
3
3
 
4
- from quelware_core.entities.clock import CurrentCount, ReferenceCount
5
4
  from quelware_core.entities.directives import Directive
6
5
  from quelware_core.entities.instrument import InstrumentStatus
7
6
  from quelware_core.entities.resource import ResourceId
@@ -35,10 +34,6 @@ class InstrumentAgent(Protocol):
35
34
  resource_ids: Collection[ResourceId],
36
35
  ) -> bool: ...
37
36
 
38
- async def get_clock_snapshot(
39
- self,
40
- ) -> tuple[CurrentCount, ReferenceCount]: ...
41
-
42
37
  async def schedule_trigger(
43
38
  self,
44
39
  token: SessionToken,
@@ -0,0 +1,18 @@
1
+ from abc import ABC, abstractmethod
2
+ from collections.abc import Collection
3
+
4
+ from quelware_core.entities.resource import ResourceId
5
+ from quelware_core.entities.session import SessionToken
6
+
7
+
8
+ class TriggerAgent(ABC):
9
+ @abstractmethod
10
+ async def trigger(
11
+ self,
12
+ token: SessionToken,
13
+ instrument_ids: Collection[ResourceId],
14
+ requested_min_wait_ms: int | None,
15
+ ) -> int: ...
16
+
17
+
18
+ __all__ = ["TriggerAgent"]
@@ -0,0 +1,7 @@
1
+ from typing import Protocol
2
+
3
+ from quelware_core.entities.clock import CurrentCount, ReferenceCount
4
+
5
+
6
+ class WorkerAgent(Protocol):
7
+ async def get_clock_snapshot(self) -> tuple[CurrentCount, ReferenceCount]: ...
@@ -3,7 +3,6 @@ from collections.abc import Collection, Sequence
3
3
  import quelware_core.pb.quelware.instrument.v1 as pb_inst
4
4
  from grpclib.client import Channel
5
5
  from quelware_core.entities import directives
6
- from quelware_core.entities.clock import CurrentCount, ReferenceCount
7
6
  from quelware_core.entities.instrument import InstrumentStatus
8
7
  from quelware_core.entities.resource import ResourceId
9
8
  from quelware_core.entities.session import SessionToken
@@ -79,16 +78,6 @@ class InstrumentAgentGrpc(InstrumentAgent):
79
78
  await call_with_retry(lambda: self._service.apply(req, metadata=metadata))
80
79
  return True
81
80
 
82
- @override
83
- async def get_clock_snapshot(
84
- self,
85
- ) -> tuple[CurrentCount, ReferenceCount]:
86
- req = pb_inst.GetClockSnapshotRequest()
87
- resp = await call_with_retry(
88
- lambda: self._service.get_clock_snapshot(req), idempotent=True
89
- )
90
- return (resp.current_count, resp.reference_count)
91
-
92
81
  @override
93
82
  async def schedule_trigger(
94
83
  self,
@@ -0,0 +1,52 @@
1
+ from collections.abc import Collection
2
+
3
+ import quelware_core.pb.quelware.trigger.v1 as pb_trigger
4
+ from betterproto2.grpclib.grpclib_client import MetadataLike
5
+ from grpclib import GRPCError
6
+ from grpclib.client import Channel
7
+ from grpclib.const import Status
8
+ from quelware_core.entities.resource import ResourceId
9
+ from quelware_core.entities.session import SessionToken
10
+
11
+ from quelware_client.core.exceptions import ServiceUnavailableError
12
+ from quelware_client.core.interfaces.trigger_agent import TriggerAgent
13
+ from quelware_client.infra._grpc_retry import call_with_retry
14
+
15
+
16
+ class TriggerAgentGrpc(TriggerAgent):
17
+ def __init__(self, grpc_channel: Channel, metadata: MetadataLike | None = None):
18
+ self._channel = grpc_channel
19
+ self._service = pb_trigger.TriggerServiceStub(self._channel, metadata=metadata)
20
+
21
+ async def trigger(
22
+ self,
23
+ token: SessionToken,
24
+ instrument_ids: Collection[ResourceId],
25
+ requested_min_wait_ms: int | None,
26
+ ) -> int:
27
+ req = pb_trigger.TriggerRequest(
28
+ instrument_ids=[str(r) for r in instrument_ids],
29
+ requested_min_wait_ms=requested_min_wait_ms,
30
+ )
31
+ metadata = dict(self._service.metadata or {})
32
+ metadata["x-session-token"] = str(token)
33
+ try:
34
+ resp = await call_with_retry(
35
+ lambda: self._service.trigger(req, metadata=metadata)
36
+ )
37
+ except GRPCError as e:
38
+ if e.status is Status.UNIMPLEMENTED:
39
+ raise ServiceUnavailableError(
40
+ f"TriggerService is not available: {e.message}"
41
+ ) from e
42
+ # Some gRPC proxies surface upstream missing-route as UNKNOWN + content-type
43
+ # error.
44
+ if e.status is Status.UNKNOWN and e.message and "content-type" in e.message:
45
+ raise ServiceUnavailableError(
46
+ f"TriggerService route not reachable via proxy: {e.message}"
47
+ ) from e
48
+ raise
49
+ return resp.scheduled_clock_count
50
+
51
+
52
+ __all__ = ["TriggerAgentGrpc"]
@@ -0,0 +1,24 @@
1
+ import quelware_core.pb.quelware.worker.v1 as pb_worker
2
+ from grpclib.client import Channel
3
+ from quelware_core.entities.clock import CurrentCount, ReferenceCount
4
+ from typing_extensions import override
5
+
6
+ from quelware_client.core.interfaces.worker_agent import WorkerAgent
7
+ from quelware_client.infra._grpc_retry import call_with_retry
8
+
9
+
10
+ class WorkerAgentGrpc(WorkerAgent):
11
+ def __init__(self, grpc_channel: Channel, metadata=None):
12
+ self._channel = grpc_channel
13
+ self._service = pb_worker.WorkerServiceStub(self._channel, metadata=metadata)
14
+
15
+ @override
16
+ async def get_clock_snapshot(self) -> tuple[CurrentCount, ReferenceCount]:
17
+ req = pb_worker.GetClockSnapshotRequest()
18
+ resp = await call_with_retry(
19
+ lambda: self._service.get_clock_snapshot(req), idempotent=True
20
+ )
21
+ return (resp.current_count, resp.reference_count)
22
+
23
+
24
+ __all__ = ["WorkerAgentGrpc"]
@@ -1,6 +1,5 @@
1
1
  from collections.abc import Collection, Sequence
2
2
 
3
- from quelware_core.entities.clock import CurrentCount, ReferenceCount
4
3
  from quelware_core.entities.directives import Directive
5
4
  from quelware_core.entities.instrument import InstrumentStatus
6
5
  from quelware_core.entities.resource import ResourceId
@@ -41,11 +40,6 @@ class InstrumentAgentMock(InstrumentAgent):
41
40
  ) -> bool:
42
41
  return True
43
42
 
44
- async def get_clock_snapshot(
45
- self,
46
- ) -> tuple[CurrentCount, ReferenceCount]:
47
- return 1234, 1000
48
-
49
43
  async def schedule_trigger(
50
44
  self,
51
45
  token: SessionToken,
@@ -8,6 +8,8 @@ from quelware_client.testing.session_agent_mock import SessionAgentMock
8
8
  from quelware_client.testing.system_configuration_agent_mock import (
9
9
  SystemConfigurationAgentMock,
10
10
  )
11
+ from quelware_client.testing.trigger_agent_mock import TriggerAgentMock
12
+ from quelware_client.testing.worker_agent_mock import WorkerAgentMock
11
13
 
12
14
 
13
15
  def create_mock_quelware_client(
@@ -19,6 +21,7 @@ def create_mock_quelware_client(
19
21
  agent_container.system_configuration = SystemConfigurationAgentMock(unit_to_status)
20
22
 
21
23
  agent_container.session = SessionAgentMock()
24
+ agent_container.trigger = TriggerAgentMock()
22
25
 
23
26
  def resource_agent_factory(ul: UnitLabel) -> ResourceAgentMock:
24
27
  rinfos = unit_to_resources.get(ul, [])
@@ -27,8 +30,12 @@ def create_mock_quelware_client(
27
30
  def instrument_agent_factory(ul: UnitLabel) -> InstrumentAgentMock:
28
31
  return InstrumentAgentMock()
29
32
 
33
+ def worker_agent_factory(ul: UnitLabel) -> WorkerAgentMock:
34
+ return WorkerAgentMock()
35
+
30
36
  return QuelwareClient(
31
37
  agent=agent_container,
32
38
  resource_agent_factory=resource_agent_factory,
33
39
  instrument_agent_factory=instrument_agent_factory,
40
+ worker_agent_factory=worker_agent_factory,
34
41
  )
@@ -0,0 +1,24 @@
1
+ from collections.abc import Collection
2
+
3
+ from quelware_core.entities.resource import ResourceId
4
+ from quelware_core.entities.session import SessionToken
5
+
6
+ from quelware_client.core.interfaces.trigger_agent import TriggerAgent
7
+
8
+
9
+ class TriggerAgentMock(TriggerAgent):
10
+ def __init__(self, scheduled_clock_count: int = 1234):
11
+ self._scheduled = scheduled_clock_count
12
+ self.calls: list[tuple[SessionToken, list[ResourceId], int | None]] = []
13
+
14
+ async def trigger(
15
+ self,
16
+ token: SessionToken,
17
+ instrument_ids: Collection[ResourceId],
18
+ requested_min_wait_ms: int | None,
19
+ ) -> int:
20
+ self.calls.append((token, list(instrument_ids), requested_min_wait_ms))
21
+ return self._scheduled
22
+
23
+
24
+ __all__ = ["TriggerAgentMock"]
@@ -0,0 +1,12 @@
1
+ from quelware_core.entities.clock import CurrentCount, ReferenceCount
2
+
3
+ from quelware_client.core.interfaces.worker_agent import WorkerAgent
4
+
5
+
6
+ class WorkerAgentMock(WorkerAgent):
7
+ def __init__(self, current_count: int = 1234, reference_count: int = 1000):
8
+ self._current_count = current_count
9
+ self._reference_count = reference_count
10
+
11
+ async def get_clock_snapshot(self) -> tuple[CurrentCount, ReferenceCount]:
12
+ return self._current_count, self._reference_count
@@ -0,0 +1,38 @@
1
+ import pytest
2
+ from quelware_core.entities.resource import ResourceId
3
+ from quelware_core.entities.session import SessionToken
4
+ from quelware_core.entities.unit import UnitLabel
5
+
6
+ from quelware_client.core import AgentContainer, Session
7
+ from quelware_client.testing.instrument_agent_mock import InstrumentAgentMock
8
+ from quelware_client.testing.trigger_agent_mock import TriggerAgentMock
9
+ from quelware_client.testing.worker_agent_mock import WorkerAgentMock
10
+
11
+
12
+ def _build_agents(
13
+ units: list[UnitLabel], trigger_agent: TriggerAgentMock
14
+ ) -> AgentContainer:
15
+ agents = AgentContainer()
16
+ agents.trigger = trigger_agent
17
+ for u in units:
18
+ agents.update_instrument_agent(u, InstrumentAgentMock())
19
+ agents.update_worker_agent(u, WorkerAgentMock())
20
+ return agents
21
+
22
+
23
+ @pytest.mark.asyncio
24
+ async def test_trigger_goes_through_manager_with_requested_wait_ms():
25
+ trig = TriggerAgentMock(scheduled_clock_count=42_000)
26
+ units = [UnitLabel("unit-a"), UnitLabel("unit-b")]
27
+ agents = _build_agents(units, trig)
28
+ rids = [ResourceId(f"{u}:i1") for u in units]
29
+ session = Session(rids, agents, token=SessionToken("tok"))
30
+
31
+ scheduled = await session.trigger(rids, wait_ms=200)
32
+
33
+ assert scheduled == 42_000
34
+ assert len(trig.calls) == 1
35
+ token, sent_ids, wait_ms = trig.calls[0]
36
+ assert token == SessionToken("tok")
37
+ assert set(sent_ids) == set(rids)
38
+ assert wait_ms == 200
@@ -940,7 +940,7 @@ wheels = [
940
940
 
941
941
  [[package]]
942
942
  name = "quelware-client"
943
- version = "0.2.0"
943
+ version = "0.3.0"
944
944
  source = { editable = "." }
945
945
  dependencies = [
946
946
  { name = "betterproto2" },
@@ -984,7 +984,7 @@ dev = [
984
984
 
985
985
  [[package]]
986
986
  name = "quelware-core"
987
- version = "0.1.4"
987
+ version = "0.2.0"
988
988
  source = { editable = "../quelware-core/python" }
989
989
  dependencies = [
990
990
  { name = "betterproto2" },
File without changes