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.
- lightfall_utils/__init__.py +3 -0
- lightfall_utils/_version.py +24 -0
- lightfall_utils/ca/__init__.py +9 -0
- lightfall_utils/ca/context.py +100 -0
- lightfall_utils/ca/pv.py +307 -0
- lightfall_utils/caproto_shutdown.py +89 -0
- lightfall_utils/config/__init__.py +12 -0
- lightfall_utils/config/layers.py +317 -0
- lightfall_utils/config/manager.py +246 -0
- lightfall_utils/log_buffer.py +231 -0
- lightfall_utils/logging.py +235 -0
- lightfall_utils/py.typed +0 -0
- lightfall_utils/qt_affinity.py +92 -0
- lightfall_utils/theming/__init__.py +30 -0
- lightfall_utils/theming/builtin.py +737 -0
- lightfall_utils/theming/manager.py +1030 -0
- lightfall_utils/theming/provider.py +102 -0
- lightfall_utils/theming/registry.py +195 -0
- lightfall_utils/threads.py +1071 -0
- lightfall_utils-0.1.0.dist-info/METADATA +54 -0
- lightfall_utils-0.1.0.dist-info/RECORD +24 -0
- lightfall_utils-0.1.0.dist-info/WHEEL +4 -0
- lightfall_utils-0.1.0.dist-info/licenses/LEGAL.md +13 -0
- lightfall_utils-0.1.0.dist-info/licenses/LICENSE.md +33 -0
|
@@ -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,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
|
lightfall_utils/ca/pv.py
ADDED
|
@@ -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
|
+
]
|