lightfall-utils 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """Shared Qt/EPICS infrastructure for ALS control applications."""
2
+
3
+ from lightfall_utils._version import __version__ # noqa: F401
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,9 @@
1
+ """Channel Access via caproto, bridged to Qt signals.
2
+
3
+ Requires the ``ca`` extra: ``pip install lightfall-utils[ca]``.
4
+ """
5
+
6
+ from lightfall_utils.ca.context import SharedContext
7
+ from lightfall_utils.ca.pv import PV
8
+
9
+ __all__ = ["SharedContext", "PV"]
@@ -0,0 +1,100 @@
1
+ """
2
+ Shared CA context management for the application.
3
+
4
+ Provides a singleton-like shared context that widgets can use to connect to PVs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import threading
10
+ from typing import TYPE_CHECKING
11
+
12
+ from caproto.threading.client import Context
13
+
14
+ if TYPE_CHECKING:
15
+ from caproto.threading.client import PV as CaprotoPV
16
+
17
+
18
+ class SharedContext:
19
+ """
20
+ Manages a shared caproto threading context for all widgets.
21
+
22
+ This class provides a singleton pattern for the CA context to avoid
23
+ creating multiple contexts and to share connections efficiently.
24
+
25
+ Attributes:
26
+ context: The underlying caproto threading Context instance.
27
+
28
+ Example:
29
+ >>> ctx = SharedContext.get_instance()
30
+ >>> pv = ctx.get_pv("MY:PV:NAME")
31
+ """
32
+
33
+ _instance: SharedContext | None = None
34
+ _lock = threading.Lock()
35
+
36
+ def __init__(self) -> None:
37
+ self._context: Context | None = None
38
+ self._pvs: dict[str, CaprotoPV] = {}
39
+
40
+ @classmethod
41
+ def get_instance(cls) -> SharedContext:
42
+ """
43
+ Get the singleton SharedContext instance.
44
+
45
+ Returns:
46
+ The shared context instance.
47
+ """
48
+ if cls._instance is None:
49
+ with cls._lock:
50
+ if cls._instance is None:
51
+ cls._instance = cls()
52
+ return cls._instance
53
+
54
+ @property
55
+ def context(self) -> Context:
56
+ """
57
+ Get the underlying caproto Context, creating it if necessary.
58
+
59
+ Returns:
60
+ The caproto threading Context.
61
+ """
62
+ if self._context is None:
63
+ self._context = Context()
64
+ return self._context
65
+
66
+ def get_pv(self, pv_name: str) -> CaprotoPV:
67
+ """
68
+ Get or create a PV connection.
69
+
70
+ Args:
71
+ pv_name: The name of the PV to connect to.
72
+
73
+ Returns:
74
+ The caproto PV object.
75
+ """
76
+ if pv_name not in self._pvs:
77
+ (pv,) = self.context.get_pvs(pv_name)
78
+ self._pvs[pv_name] = pv
79
+ return self._pvs[pv_name]
80
+
81
+ def clear(self) -> None:
82
+ """
83
+ Clear all cached PVs and reset the context.
84
+
85
+ Useful for testing or when reconfiguring the connection.
86
+ """
87
+ self._pvs.clear()
88
+ self._context = None
89
+
90
+ @classmethod
91
+ def reset(cls) -> None:
92
+ """
93
+ Reset the singleton instance entirely.
94
+
95
+ Primarily used for testing to ensure a clean state.
96
+ """
97
+ with cls._lock:
98
+ if cls._instance is not None:
99
+ cls._instance.clear()
100
+ cls._instance = None
@@ -0,0 +1,307 @@
1
+ """
2
+ PV wrapper that bridges caproto to Qt signals.
3
+
4
+ Provides a Qt-friendly interface to EPICS PVs with signals for value changes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import threading
10
+ from typing import Any
11
+
12
+ from PySide6.QtCore import QObject, Qt, Signal, Slot
13
+
14
+ from lightfall_utils.qt_affinity import gui_thread_only
15
+
16
+
17
+ class PV(QObject):
18
+ """
19
+ A Qt-aware wrapper around a caproto PV.
20
+
21
+ Emits Qt signals when the PV value changes, making it easy to connect
22
+ to widget slots for automatic UI updates.
23
+
24
+ Attributes:
25
+ pv_name: The EPICS PV name this object represents.
26
+ connected: Whether the PV is currently connected.
27
+ value: The current PV value.
28
+
29
+ Signals:
30
+ value_changed: Emitted when the PV value changes. Carries the new value.
31
+ connection_changed: Emitted when connection state changes. Carries bool.
32
+ metadata_changed: Emitted when PV metadata changes (units, limits, etc).
33
+
34
+ Example:
35
+ >>> pv = PV("MY:PV:NAME")
36
+ >>> pv.value_changed.connect(my_label.setText)
37
+ >>> pv.connect()
38
+ """
39
+
40
+ value_changed = Signal(object)
41
+ connection_changed = Signal(bool)
42
+ metadata_changed = Signal(dict)
43
+
44
+ # Internal signals for thread-safe updates from caproto callbacks
45
+ _value_received = Signal(object)
46
+ _connection_ready = Signal(bool)
47
+
48
+ def __init__(
49
+ self,
50
+ pv_name: str,
51
+ parent: QObject | None = None,
52
+ auto_connect: bool = False,
53
+ ) -> None:
54
+ """
55
+ Initialize a PV wrapper.
56
+
57
+ Args:
58
+ pv_name: The EPICS PV name to connect to.
59
+ parent: Optional Qt parent object.
60
+ auto_connect: If True, connect immediately on creation.
61
+ """
62
+ super().__init__(parent)
63
+ self._pv_name = pv_name
64
+ self._connected = False
65
+ self._value: Any = None
66
+ self._metadata: dict[str, Any] = {}
67
+ self._caproto_pv = None
68
+ self._subscription = None
69
+
70
+ # Connect internal signals for thread-safe updates
71
+ self._value_received.connect(
72
+ self._handle_value_received, Qt.ConnectionType.QueuedConnection
73
+ )
74
+ self._connection_ready.connect(
75
+ self._handle_connection_ready, Qt.ConnectionType.QueuedConnection
76
+ )
77
+
78
+ if auto_connect:
79
+ self.connect_pv()
80
+
81
+ @property
82
+ def pv_name(self) -> str:
83
+ """The EPICS PV name."""
84
+ return self._pv_name
85
+
86
+ @property
87
+ def connected(self) -> bool:
88
+ """Whether the PV is currently connected."""
89
+ return self._connected
90
+
91
+ @property
92
+ def value(self) -> Any:
93
+ """The current PV value."""
94
+ return self._value
95
+
96
+ @property
97
+ def metadata(self) -> dict[str, Any]:
98
+ """
99
+ PV metadata including units, limits, precision, enum strings, etc.
100
+
101
+ Returns:
102
+ Dictionary with keys like 'units', 'lower_limit', 'upper_limit',
103
+ 'precision', 'enum_strings', etc. depending on the PV type.
104
+ """
105
+ return self._metadata.copy()
106
+
107
+ def connect_pv(self) -> None:
108
+ """
109
+ Establish connection to the PV and start monitoring.
110
+
111
+ This method is safe to call multiple times - it will only connect once.
112
+ Connection is performed in a background thread to avoid blocking the GUI.
113
+ """
114
+ if self._caproto_pv is not None:
115
+ return
116
+
117
+ # Start connection in background thread to avoid blocking
118
+ thread = threading.Thread(target=self._connect_pv_blocking, daemon=True)
119
+ thread.start()
120
+
121
+ def _connect_pv_blocking(self) -> None:
122
+ """
123
+ Blocking connection logic - runs in background thread.
124
+ """
125
+ from lightfall_utils.ca.context import SharedContext
126
+
127
+ ctx = SharedContext.get_instance()
128
+ self._caproto_pv = ctx.get_pv(self._pv_name)
129
+
130
+ # Wait for connection with timeout
131
+ try:
132
+ self._caproto_pv.wait_for_connection(timeout=5.0)
133
+ # Signal main thread that connection succeeded
134
+ self._connection_ready.emit(True)
135
+ except TimeoutError:
136
+ self._connection_ready.emit(False)
137
+
138
+ @Slot(bool)
139
+ @gui_thread_only
140
+ def _handle_connection_ready(self, connected: bool) -> None:
141
+ """
142
+ Handle connection completion in the main Qt thread.
143
+
144
+ Args:
145
+ connected: Whether connection succeeded.
146
+ """
147
+ self._connected = connected
148
+ self.connection_changed.emit(connected)
149
+
150
+ if not connected:
151
+ return
152
+
153
+ # Read initial value and metadata
154
+ self._read_metadata()
155
+ self._read_initial_value()
156
+
157
+ # Subscribe to value changes
158
+ self._subscription = self._caproto_pv.subscribe(data_type="time")
159
+ self._subscription.add_callback(self._on_value_change)
160
+
161
+ def disconnect_pv(self) -> None:
162
+ """
163
+ Disconnect from the PV and stop monitoring.
164
+ """
165
+ if self._subscription is not None:
166
+ self._subscription.clear()
167
+ self._subscription = None
168
+
169
+ self._caproto_pv = None
170
+ self._connected = False
171
+ self.connection_changed.emit(False)
172
+
173
+ def put(self, value: Any, wait: bool = False, timeout: float = 5.0) -> None:
174
+ """
175
+ Write a value to the PV.
176
+
177
+ Args:
178
+ value: The value to write.
179
+ wait: If True, block until the write completes.
180
+ timeout: Timeout in seconds if wait is True.
181
+
182
+ Raises:
183
+ RuntimeError: If PV is not connected.
184
+ """
185
+ if not self._connected or self._caproto_pv is None:
186
+ raise RuntimeError(f"PV {self._pv_name} is not connected")
187
+
188
+ self._caproto_pv.write(value, wait=wait, timeout=timeout)
189
+
190
+ def _on_value_change(self, sub: Any, response: Any) -> None:
191
+ """
192
+ Callback for PV value changes from caproto subscription.
193
+
194
+ This runs in a caproto background thread, so we use a queued
195
+ signal connection to safely update the Qt main thread.
196
+
197
+ Args:
198
+ sub: The subscription object.
199
+ response: The caproto subscription response.
200
+ """
201
+ value = response.data
202
+ # Handle array vs scalar
203
+ if hasattr(value, "__len__") and len(value) == 1:
204
+ value = value[0]
205
+ # Emit to internal signal which is queued to main thread
206
+ self._value_received.emit(value)
207
+
208
+ @Slot(object)
209
+ @gui_thread_only
210
+ def _handle_value_received(self, value: Any) -> None:
211
+ """
212
+ Handle value update in the main Qt thread.
213
+
214
+ Args:
215
+ value: The new PV value.
216
+ """
217
+ self._value = value
218
+ self.value_changed.emit(value)
219
+
220
+ def _read_metadata(self) -> None:
221
+ """
222
+ Read and cache PV metadata (units, limits, etc).
223
+ """
224
+ if self._caproto_pv is None:
225
+ return
226
+
227
+ try:
228
+ # Read with control data type to get metadata
229
+ result = self._caproto_pv.read(data_type="control")
230
+
231
+ metadata: dict[str, Any] = {}
232
+
233
+ # Metadata is in result.metadata for caproto
234
+ meta = getattr(result, "metadata", None)
235
+ if meta is None:
236
+ meta = result
237
+
238
+ # Extract common metadata fields
239
+ if hasattr(meta, "units"):
240
+ units = meta.units
241
+ metadata["units"] = units.decode() if isinstance(units, bytes) else units
242
+
243
+ if hasattr(meta, "lower_ctrl_limit"):
244
+ metadata["lower_limit"] = meta.lower_ctrl_limit
245
+
246
+ if hasattr(meta, "upper_ctrl_limit"):
247
+ metadata["upper_limit"] = meta.upper_ctrl_limit
248
+
249
+ if hasattr(meta, "precision"):
250
+ metadata["precision"] = meta.precision
251
+
252
+ if hasattr(meta, "enum_strings"):
253
+ metadata["enum_strings"] = [
254
+ s.decode() if isinstance(s, bytes) else s
255
+ for s in meta.enum_strings
256
+ if s # Filter out empty strings
257
+ ]
258
+
259
+ self._metadata = metadata
260
+ self.metadata_changed.emit(metadata)
261
+
262
+ except Exception:
263
+ # Metadata read failed - continue without it
264
+ pass
265
+
266
+ def _read_initial_value(self) -> None:
267
+ """
268
+ Read and emit the initial PV value.
269
+ """
270
+ if self._caproto_pv is None:
271
+ return
272
+
273
+ try:
274
+ result = self._caproto_pv.read()
275
+ value = result.data
276
+ # Handle array vs scalar
277
+ if hasattr(value, "__len__") and len(value) == 1:
278
+ value = value[0]
279
+ self._value = value
280
+ self.value_changed.emit(value)
281
+ except Exception:
282
+ # Initial read failed - subscription will provide value
283
+ pass
284
+
285
+ def get_introspection_data(self) -> dict[str, Any]:
286
+ """
287
+ Get detailed introspection data for this PV.
288
+
289
+ This method is designed to support Claude MCP tools that inspect
290
+ the widget tree. It provides all relevant information about the
291
+ PV in a structured format.
292
+
293
+ Returns:
294
+ Dictionary containing:
295
+ - pv_name: The EPICS PV name
296
+ - connected: Connection status
297
+ - value: Current value
298
+ - metadata: All cached metadata
299
+ - type: String representation of the value type
300
+ """
301
+ return {
302
+ "pv_name": self._pv_name,
303
+ "connected": self._connected,
304
+ "value": self._value,
305
+ "value_type": type(self._value).__name__ if self._value is not None else None,
306
+ "metadata": self._metadata,
307
+ }
@@ -0,0 +1,89 @@
1
+ """Stop caproto's user-callback thread pools cleanly at application shutdown.
2
+
3
+ Each connected caproto virtual circuit owns a *non-daemon*
4
+ ``ThreadPoolExecutor`` (``user_callback_executor``) that runs user
5
+ subscription/read/write callbacks. ``concurrent.futures`` registers an
6
+ ``atexit`` hook that joins every executor's worker threads at interpreter
7
+ shutdown, so a callback still in flight there stalls exit -- the reason
8
+ host applications may install a force-exit watchdog. Draining these
9
+ executors (without tearing down sockets) lets the process exit on its own.
10
+
11
+ ``get_caproto_context`` never *creates* a context: at shutdown we only want
12
+ the one caproto already built, not a fresh one (which would spawn threads).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ from lightfall_utils.logging import logger
20
+
21
+ __all__ = ["get_caproto_context", "drain_callback_executors", "disconnect_context"]
22
+
23
+
24
+ def get_caproto_context() -> Any | None:
25
+ """Return caproto's already-created shared Context, or ``None``.
26
+
27
+ Returns ``None`` when caproto isn't installed, its threading control layer
28
+ was never used, or anything goes wrong. Never creates a new context (that
29
+ would spin up broadcaster/selector threads during shutdown).
30
+ """
31
+ try:
32
+ from caproto.threading.pyepics_compat import _make_context
33
+
34
+ # _make_context is functools.lru_cache(1). Only return a context if one
35
+ # was actually built (currsize > 0) so we never construct one here.
36
+ if _make_context.cache_info().currsize == 0:
37
+ return None
38
+ return _make_context()
39
+ except Exception:
40
+ return None
41
+
42
+
43
+ def drain_callback_executors(ctx: Any | None) -> int:
44
+ """Shut down every circuit's user-callback ``ThreadPoolExecutor``.
45
+
46
+ Uses ``wait=False, cancel_futures=True``: queued callbacks are dropped and
47
+ no new work is accepted, so the non-daemon worker threads finish and stop
48
+ blocking interpreter exit. Does **not** touch sockets, so it cannot trigger
49
+ the socket-teardown crash that a full ``Context.disconnect()`` is suspected
50
+ of. Returns the number of executors drained. Never raises.
51
+ """
52
+ if ctx is None:
53
+ return 0
54
+ drained = 0
55
+ try:
56
+ circuit_managers = getattr(ctx, "circuit_managers", None) or {}
57
+ for cm in list(circuit_managers.values()):
58
+ executor = getattr(cm, "user_callback_executor", None)
59
+ if executor is None:
60
+ continue
61
+ try:
62
+ executor.shutdown(wait=False, cancel_futures=True)
63
+ drained += 1
64
+ except Exception as e:
65
+ logger.warning("Failed to shut down a caproto callback executor: {}", e)
66
+ except Exception as e:
67
+ logger.warning("Draining caproto callback executors failed: {}", e)
68
+ return drained
69
+
70
+
71
+ def disconnect_context(ctx: Any | None) -> bool:
72
+ """Fully disconnect a caproto ``Context`` (circuits + sockets + selector).
73
+
74
+ This is the thorough teardown -- besides draining the callback executors it
75
+ also stops caproto's selector/circuit threads before interpreter
76
+ finalization, which the drain path deliberately leaves running. The socket
77
+ teardown here is what has historically been suspected of a Windows access
78
+ violation, so callers gate this behind an opt-in. Returns ``True`` if
79
+ ``disconnect()`` was invoked. Never raises at the Python level (a hard
80
+ native crash, if it really happens, cannot be caught here).
81
+ """
82
+ if ctx is None:
83
+ return False
84
+ try:
85
+ ctx.disconnect(wait=False)
86
+ return True
87
+ except Exception as e:
88
+ logger.warning("caproto Context.disconnect() failed: {}", e)
89
+ return False
@@ -0,0 +1,12 @@
1
+ """Priority-layered configuration with pydantic validation."""
2
+
3
+ from lightfall_utils.config.layers import ConfigLayer, ConfigPriority, LayeredConfig
4
+ from lightfall_utils.config.manager import ConfigManager, PermissiveModel
5
+
6
+ __all__ = [
7
+ "ConfigLayer",
8
+ "ConfigPriority",
9
+ "LayeredConfig",
10
+ "ConfigManager",
11
+ "PermissiveModel",
12
+ ]