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,231 @@
|
|
|
1
|
+
"""In-process ring buffer of recent log records.
|
|
2
|
+
|
|
3
|
+
Provides a thread-safe singleton that captures all log records (at the
|
|
4
|
+
configured level and above) into a bounded deque for diagnostics tooling
|
|
5
|
+
to look back at what happened in the moments before something unexpected.
|
|
6
|
+
|
|
7
|
+
Captures the full tail (DEBUG/INFO/WARNING/ERROR) for comprehensive logging
|
|
8
|
+
inspection and diagnostics.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import threading
|
|
14
|
+
from collections import deque
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import UTC, datetime, timedelta
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from loguru import Record
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_LEVEL_NO = {
|
|
24
|
+
"TRACE": 5,
|
|
25
|
+
"DEBUG": 10,
|
|
26
|
+
"INFO": 20,
|
|
27
|
+
"SUCCESS": 25,
|
|
28
|
+
"WARNING": 30,
|
|
29
|
+
"ERROR": 40,
|
|
30
|
+
"CRITICAL": 50,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class LogRecord:
|
|
36
|
+
"""A captured log record.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
timestamp: When the record was emitted (UTC).
|
|
40
|
+
level: Log level name (DEBUG, INFO, WARNING, ERROR, CRITICAL).
|
|
41
|
+
level_no: Numeric level from loguru (DEBUG=10 ... CRITICAL=50).
|
|
42
|
+
name: Logger name (typically the module path).
|
|
43
|
+
function: Function where the record was emitted.
|
|
44
|
+
line: Line number where the record was emitted.
|
|
45
|
+
thread: Thread name.
|
|
46
|
+
message: The formatted log message.
|
|
47
|
+
exception_info: Formatted exception traceback, if any.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
timestamp: datetime
|
|
51
|
+
level: str
|
|
52
|
+
level_no: int
|
|
53
|
+
name: str
|
|
54
|
+
function: str
|
|
55
|
+
line: int
|
|
56
|
+
thread: str
|
|
57
|
+
message: str
|
|
58
|
+
exception_info: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class LogBuffer:
|
|
62
|
+
"""Singleton ring buffer of recent loguru records.
|
|
63
|
+
|
|
64
|
+
Install once at application startup (after :func:`configure_logging`)
|
|
65
|
+
via :meth:`install`. Subsequent calls are no-ops. Use
|
|
66
|
+
:meth:`get_records` to retrieve filtered tails.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
DEFAULT_MAX_RECORDS = 10_000
|
|
70
|
+
DEFAULT_LEVEL = "DEBUG"
|
|
71
|
+
|
|
72
|
+
_instance: LogBuffer | None = None
|
|
73
|
+
_instance_lock = threading.Lock()
|
|
74
|
+
|
|
75
|
+
def __new__(cls) -> LogBuffer:
|
|
76
|
+
if cls._instance is None:
|
|
77
|
+
with cls._instance_lock:
|
|
78
|
+
if cls._instance is None:
|
|
79
|
+
cls._instance = super().__new__(cls)
|
|
80
|
+
cls._instance._initialized = False
|
|
81
|
+
return cls._instance
|
|
82
|
+
|
|
83
|
+
def __init__(self) -> None:
|
|
84
|
+
if self._initialized:
|
|
85
|
+
return
|
|
86
|
+
self._initialized = True
|
|
87
|
+
|
|
88
|
+
self._records: deque[LogRecord] = deque(maxlen=self.DEFAULT_MAX_RECORDS)
|
|
89
|
+
self._buffer_lock = threading.Lock()
|
|
90
|
+
self._sink_id: int | None = None
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def get_instance(cls) -> LogBuffer:
|
|
94
|
+
"""Return the LogBuffer singleton."""
|
|
95
|
+
return cls()
|
|
96
|
+
|
|
97
|
+
def install(
|
|
98
|
+
self,
|
|
99
|
+
level: str = DEFAULT_LEVEL,
|
|
100
|
+
max_records: int = DEFAULT_MAX_RECORDS,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Install the loguru sink. Safe to call multiple times.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
level: Minimum log level to capture (TRACE/DEBUG/INFO/...).
|
|
106
|
+
max_records: Ring buffer capacity. The oldest record is
|
|
107
|
+
evicted when full.
|
|
108
|
+
"""
|
|
109
|
+
if self._sink_id is not None:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
from loguru import logger
|
|
113
|
+
|
|
114
|
+
if max_records != self._records.maxlen:
|
|
115
|
+
with self._buffer_lock:
|
|
116
|
+
self._records = deque(self._records, maxlen=max_records)
|
|
117
|
+
|
|
118
|
+
self._sink_id = logger.add(
|
|
119
|
+
self._sink,
|
|
120
|
+
level=level,
|
|
121
|
+
format="{message}",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def uninstall(self) -> None:
|
|
125
|
+
"""Remove the loguru sink. Primarily useful for testing."""
|
|
126
|
+
if self._sink_id is None:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
from loguru import logger
|
|
130
|
+
|
|
131
|
+
logger.remove(self._sink_id)
|
|
132
|
+
self._sink_id = None
|
|
133
|
+
|
|
134
|
+
def clear(self) -> None:
|
|
135
|
+
"""Drop all captured records."""
|
|
136
|
+
with self._buffer_lock:
|
|
137
|
+
self._records.clear()
|
|
138
|
+
|
|
139
|
+
def __len__(self) -> int:
|
|
140
|
+
with self._buffer_lock:
|
|
141
|
+
return len(self._records)
|
|
142
|
+
|
|
143
|
+
def get_records(
|
|
144
|
+
self,
|
|
145
|
+
*,
|
|
146
|
+
level: str | None = None,
|
|
147
|
+
since: datetime | None = None,
|
|
148
|
+
since_seconds: float | None = None,
|
|
149
|
+
contains: str | None = None,
|
|
150
|
+
name_prefix: str | None = None,
|
|
151
|
+
max_count: int | None = None,
|
|
152
|
+
) -> list[LogRecord]:
|
|
153
|
+
"""Return a filtered tail of recent records, newest first.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
level: Minimum level filter (e.g. ``"WARNING"`` returns
|
|
157
|
+
WARNING/ERROR/CRITICAL only). Case-insensitive.
|
|
158
|
+
since: Return records with timestamp >= this datetime
|
|
159
|
+
(UTC; naive datetimes are treated as UTC).
|
|
160
|
+
since_seconds: Return records emitted within the last N
|
|
161
|
+
seconds. Mutually exclusive with ``since``; if both are
|
|
162
|
+
given, the more restrictive one wins.
|
|
163
|
+
contains: Case-insensitive substring filter on the message.
|
|
164
|
+
name_prefix: Return only records whose logger name starts
|
|
165
|
+
with this prefix (e.g. ``"myapp.devices"``).
|
|
166
|
+
max_count: Cap on returned records. None = unbounded.
|
|
167
|
+
"""
|
|
168
|
+
min_level_no = _LEVEL_NO.get(level.upper(), 0) if level else 0
|
|
169
|
+
|
|
170
|
+
cutoffs: list[datetime] = []
|
|
171
|
+
if since is not None:
|
|
172
|
+
cutoffs.append(since if since.tzinfo else since.replace(tzinfo=UTC))
|
|
173
|
+
if since_seconds is not None:
|
|
174
|
+
cutoffs.append(datetime.now(UTC) - timedelta(seconds=float(since_seconds)))
|
|
175
|
+
cutoff = max(cutoffs) if cutoffs else None
|
|
176
|
+
|
|
177
|
+
contains_lc = contains.lower() if contains else None
|
|
178
|
+
|
|
179
|
+
with self._buffer_lock:
|
|
180
|
+
records = list(self._records)
|
|
181
|
+
|
|
182
|
+
out: list[LogRecord] = []
|
|
183
|
+
for rec in reversed(records):
|
|
184
|
+
if rec.level_no < min_level_no:
|
|
185
|
+
continue
|
|
186
|
+
if cutoff is not None and rec.timestamp < cutoff:
|
|
187
|
+
continue
|
|
188
|
+
if name_prefix is not None and not rec.name.startswith(name_prefix):
|
|
189
|
+
continue
|
|
190
|
+
if contains_lc is not None and contains_lc not in rec.message.lower():
|
|
191
|
+
continue
|
|
192
|
+
out.append(rec)
|
|
193
|
+
if max_count is not None and len(out) >= max_count:
|
|
194
|
+
break
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
def _sink(self, message) -> None:
|
|
198
|
+
record: Record = message.record
|
|
199
|
+
|
|
200
|
+
exception_info: str | None = None
|
|
201
|
+
if record["exception"] is not None:
|
|
202
|
+
exc = record["exception"]
|
|
203
|
+
if exc.traceback:
|
|
204
|
+
import traceback
|
|
205
|
+
|
|
206
|
+
exception_info = "".join(
|
|
207
|
+
traceback.format_exception(exc.type, exc.value, exc.traceback)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
thread = record.get("thread")
|
|
211
|
+
thread_name = thread.name if thread is not None else ""
|
|
212
|
+
|
|
213
|
+
log_record = LogRecord(
|
|
214
|
+
timestamp=datetime.now(UTC),
|
|
215
|
+
level=record["level"].name,
|
|
216
|
+
level_no=record["level"].no,
|
|
217
|
+
name=record["name"] or "",
|
|
218
|
+
function=record["function"] or "",
|
|
219
|
+
line=record["line"] or 0,
|
|
220
|
+
thread=thread_name,
|
|
221
|
+
message=record["message"],
|
|
222
|
+
exception_info=exception_info,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
with self._buffer_lock:
|
|
226
|
+
self._records.append(log_record)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def get_log_buffer() -> LogBuffer:
|
|
230
|
+
"""Convenience accessor for the LogBuffer singleton."""
|
|
231
|
+
return LogBuffer.get_instance()
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Logging abstraction module.
|
|
2
|
+
|
|
3
|
+
Provides centralized logging configuration and timing utilities built on loguru.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from collections.abc import Generator, Sequence
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from loguru import logger
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from loguru import Record
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"logger",
|
|
24
|
+
"configure_logging",
|
|
25
|
+
"log_time",
|
|
26
|
+
"get_cumulative_stats",
|
|
27
|
+
"reset_cumulative_stats",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# Thread-safe cumulative timing storage
|
|
31
|
+
_timing_lock = threading.Lock()
|
|
32
|
+
_cumulative_time: dict[str, int] = defaultdict(int)
|
|
33
|
+
_cumulative_count: dict[str, int] = defaultdict(int)
|
|
34
|
+
_application_start_time = time.perf_counter_ns()
|
|
35
|
+
|
|
36
|
+
# Track whether logging has been configured
|
|
37
|
+
_configured = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Module-name prefixes muted at DEBUG level; set via configure_logging().
|
|
41
|
+
_mute_debug_modules: tuple[str, ...] = ()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _third_party_filter(record: Record) -> bool:
|
|
45
|
+
"""Filter out DEBUG messages from noisy third-party modules."""
|
|
46
|
+
if record["level"].no <= 10: # DEBUG = 10
|
|
47
|
+
name = record["name"] or ""
|
|
48
|
+
return not any(name.startswith(mod) for mod in _mute_debug_modules)
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _default_format(record: Record) -> str:
|
|
53
|
+
"""Default log format."""
|
|
54
|
+
level_colors = {
|
|
55
|
+
"TRACE": "dim",
|
|
56
|
+
"DEBUG": "cyan",
|
|
57
|
+
"INFO": "green",
|
|
58
|
+
"SUCCESS": "bold green",
|
|
59
|
+
"WARNING": "yellow",
|
|
60
|
+
"ERROR": "red",
|
|
61
|
+
"CRITICAL": "bold red",
|
|
62
|
+
}
|
|
63
|
+
color = level_colors.get(record["level"].name, "")
|
|
64
|
+
return (
|
|
65
|
+
"<dim>{time:YYYY-MM-DD HH:mm:ss.SSS}</dim> | "
|
|
66
|
+
f"<{color}>{{level: <8}}</{color}> | "
|
|
67
|
+
"<dim>{thread.name}</dim> | "
|
|
68
|
+
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
|
|
69
|
+
"{message}\n{exception}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def configure_logging(
|
|
74
|
+
*,
|
|
75
|
+
level: str = "INFO",
|
|
76
|
+
log_file: Path | str | None = None,
|
|
77
|
+
rotation: str = "10 MB",
|
|
78
|
+
retention: str = "1 week",
|
|
79
|
+
console: bool = True,
|
|
80
|
+
colorize: bool = True,
|
|
81
|
+
format_string: str | None = None,
|
|
82
|
+
mute_debug_modules: Sequence[str] = (),
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Configure logging for the application.
|
|
85
|
+
|
|
86
|
+
This should be called once at application startup. Subsequent calls
|
|
87
|
+
will reconfigure logging.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
level: Minimum log level to display (TRACE, DEBUG, INFO, WARNING, ERROR, CRITICAL).
|
|
91
|
+
log_file: Optional path to a log file. If provided, logs will also be written to file.
|
|
92
|
+
rotation: When to rotate the log file (e.g., "10 MB", "1 day", "00:00").
|
|
93
|
+
retention: How long to keep old log files (e.g., "1 week", "10 days").
|
|
94
|
+
console: Whether to log to the console (stderr).
|
|
95
|
+
colorize: Whether to use colors in console output.
|
|
96
|
+
format_string: Custom format string. If None, uses the default format.
|
|
97
|
+
mute_debug_modules: Module-name prefixes whose DEBUG/TRACE records are
|
|
98
|
+
dropped (for noisy third-party libraries).
|
|
99
|
+
"""
|
|
100
|
+
global _configured, _mute_debug_modules
|
|
101
|
+
_mute_debug_modules = tuple(mute_debug_modules)
|
|
102
|
+
|
|
103
|
+
# Remove all existing handlers
|
|
104
|
+
logger.remove()
|
|
105
|
+
|
|
106
|
+
fmt = format_string if format_string else _default_format
|
|
107
|
+
|
|
108
|
+
if console and sys.stderr is not None:
|
|
109
|
+
logger.add(
|
|
110
|
+
sys.stderr,
|
|
111
|
+
level=level,
|
|
112
|
+
format=fmt,
|
|
113
|
+
colorize=colorize,
|
|
114
|
+
filter=_third_party_filter,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if log_file:
|
|
118
|
+
log_path = Path(log_file)
|
|
119
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
logger.add(
|
|
121
|
+
log_path,
|
|
122
|
+
level=level,
|
|
123
|
+
format=fmt,
|
|
124
|
+
rotation=rotation,
|
|
125
|
+
retention=retention,
|
|
126
|
+
compression="gz",
|
|
127
|
+
filter=_third_party_filter,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
_configured = True
|
|
131
|
+
logger.debug("Logging configured with level={}", level)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@contextmanager
|
|
135
|
+
def log_time(
|
|
136
|
+
*args: Any,
|
|
137
|
+
level: str | int = "INFO",
|
|
138
|
+
cumulative_key: str = "",
|
|
139
|
+
precision: int = 3,
|
|
140
|
+
) -> Generator[None, None, None]:
|
|
141
|
+
"""Context manager to log the elapsed time of a code block.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
*args: Message components to log (joined with spaces).
|
|
145
|
+
level: Log level for the timing message.
|
|
146
|
+
cumulative_key: If provided, accumulates timing statistics under this key.
|
|
147
|
+
Use get_cumulative_stats() to retrieve aggregated data.
|
|
148
|
+
precision: Decimal places for millisecond display.
|
|
149
|
+
|
|
150
|
+
Yields:
|
|
151
|
+
None
|
|
152
|
+
|
|
153
|
+
Example:
|
|
154
|
+
with log_time("Processing data"):
|
|
155
|
+
process_data()
|
|
156
|
+
|
|
157
|
+
with log_time("Database query", cumulative_key="db_queries"):
|
|
158
|
+
run_query()
|
|
159
|
+
"""
|
|
160
|
+
start = time.perf_counter_ns()
|
|
161
|
+
try:
|
|
162
|
+
yield
|
|
163
|
+
finally:
|
|
164
|
+
elapsed_ns = time.perf_counter_ns() - start
|
|
165
|
+
elapsed_ms = elapsed_ns / 1e6
|
|
166
|
+
|
|
167
|
+
message_parts = [str(arg) for arg in args]
|
|
168
|
+
|
|
169
|
+
if cumulative_key:
|
|
170
|
+
with _timing_lock:
|
|
171
|
+
_cumulative_time[cumulative_key] += elapsed_ns
|
|
172
|
+
_cumulative_count[cumulative_key] += 1
|
|
173
|
+
total_ms = _cumulative_time[cumulative_key] / 1e6
|
|
174
|
+
count = _cumulative_count[cumulative_key]
|
|
175
|
+
avg_ms = total_ms / count
|
|
176
|
+
app_elapsed = time.perf_counter_ns() - _application_start_time
|
|
177
|
+
profile_pct = (_cumulative_time[cumulative_key] / app_elapsed) * 100
|
|
178
|
+
|
|
179
|
+
message_parts.append(
|
|
180
|
+
f"[elapsed: {elapsed_ms:.{precision}f} ms | "
|
|
181
|
+
f"cumulative: {total_ms:.{precision}f} ms | "
|
|
182
|
+
f"avg: {avg_ms:.{precision}f} ms | "
|
|
183
|
+
f"calls: {count} | "
|
|
184
|
+
f"profile: {profile_pct:.1f}%]"
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
message_parts.append(f"[elapsed: {elapsed_ms:.{precision}f} ms]")
|
|
188
|
+
|
|
189
|
+
logger.log(level, " ".join(message_parts))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def get_cumulative_stats(key: str | None = None) -> dict[str, dict[str, float]]:
|
|
193
|
+
"""Get cumulative timing statistics.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
key: Specific key to retrieve. If None, returns all statistics.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Dictionary mapping keys to their statistics:
|
|
200
|
+
- total_ms: Total accumulated time in milliseconds
|
|
201
|
+
- count: Number of calls
|
|
202
|
+
- avg_ms: Average time per call in milliseconds
|
|
203
|
+
- profile_pct: Percentage of application runtime
|
|
204
|
+
"""
|
|
205
|
+
with _timing_lock:
|
|
206
|
+
app_elapsed = time.perf_counter_ns() - _application_start_time
|
|
207
|
+
|
|
208
|
+
def make_stats(k: str) -> dict[str, float]:
|
|
209
|
+
total_ns = _cumulative_time[k]
|
|
210
|
+
count = _cumulative_count[k]
|
|
211
|
+
return {
|
|
212
|
+
"total_ms": total_ns / 1e6,
|
|
213
|
+
"count": float(count),
|
|
214
|
+
"avg_ms": (total_ns / 1e6 / count) if count > 0 else 0.0,
|
|
215
|
+
"profile_pct": (total_ns / app_elapsed * 100) if app_elapsed > 0 else 0.0,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if key is not None:
|
|
219
|
+
return {key: make_stats(key)}
|
|
220
|
+
return {k: make_stats(k) for k in _cumulative_time}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def reset_cumulative_stats(key: str | None = None) -> None:
|
|
224
|
+
"""Reset cumulative timing statistics.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
key: Specific key to reset. If None, resets all statistics.
|
|
228
|
+
"""
|
|
229
|
+
with _timing_lock:
|
|
230
|
+
if key is not None:
|
|
231
|
+
_cumulative_time.pop(key, None)
|
|
232
|
+
_cumulative_count.pop(key, None)
|
|
233
|
+
else:
|
|
234
|
+
_cumulative_time.clear()
|
|
235
|
+
_cumulative_count.clear()
|
lightfall_utils/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Qt thread-affinity assertions.
|
|
2
|
+
|
|
3
|
+
Helpers to catch the classic Qt failure mode of touching GUI objects from
|
|
4
|
+
a worker thread. Extracted from Lightfall's crash diagnostics.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from functools import wraps
|
|
12
|
+
from typing import Any, TypeVar
|
|
13
|
+
|
|
14
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
15
|
+
|
|
16
|
+
__all__ = ["assert_gui_thread", "assert_object_thread", "gui_thread_only"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def assert_gui_thread(obj: Any | None = None) -> None:
|
|
20
|
+
"""Raise ``RuntimeError`` if not running on the QApplication's thread.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
obj: Optional QObject. If supplied, its ``thread()`` is included
|
|
24
|
+
in the error message — useful when a widget appears to belong
|
|
25
|
+
to the wrong thread.
|
|
26
|
+
"""
|
|
27
|
+
from PySide6.QtCore import QCoreApplication, QThread
|
|
28
|
+
|
|
29
|
+
app = QCoreApplication.instance()
|
|
30
|
+
if app is None:
|
|
31
|
+
# No QApplication yet — affinity isn't meaningful. Be permissive.
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
current = QThread.currentThread()
|
|
35
|
+
gui_thread = app.thread()
|
|
36
|
+
if current is gui_thread:
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
py_thread = threading.current_thread()
|
|
40
|
+
parts = [
|
|
41
|
+
f"GUI thread assertion failed: called from "
|
|
42
|
+
f"{py_thread.name!r} (QThread={current!r}), "
|
|
43
|
+
f"expected GUI thread (QThread={gui_thread!r})",
|
|
44
|
+
]
|
|
45
|
+
if obj is not None:
|
|
46
|
+
try:
|
|
47
|
+
obj_thread = obj.thread()
|
|
48
|
+
parts.append(f"; object {obj!r}.thread()={obj_thread!r}")
|
|
49
|
+
except Exception as e:
|
|
50
|
+
parts.append(f"; object thread() raised: {e!r}")
|
|
51
|
+
|
|
52
|
+
raise RuntimeError("".join(parts))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def assert_object_thread(obj: Any) -> None:
|
|
56
|
+
"""Raise ``RuntimeError`` if the current thread is not ``obj.thread()``.
|
|
57
|
+
|
|
58
|
+
The mirror of ``assert_gui_thread`` for QObjects that legitimately
|
|
59
|
+
live on a non-GUI thread (a worker, a background QObject moved to
|
|
60
|
+
a QThread, etc).
|
|
61
|
+
"""
|
|
62
|
+
from PySide6.QtCore import QThread
|
|
63
|
+
|
|
64
|
+
obj_thread = obj.thread()
|
|
65
|
+
current = QThread.currentThread()
|
|
66
|
+
if current is obj_thread:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
py_thread = threading.current_thread()
|
|
70
|
+
raise RuntimeError(
|
|
71
|
+
f"Object-thread assertion failed: called from "
|
|
72
|
+
f"{py_thread.name!r} (QThread={current!r}), "
|
|
73
|
+
f"expected {obj!r}.thread()={obj_thread!r}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def gui_thread_only(func: F) -> F:
|
|
78
|
+
"""Wrap a method/slot so it raises if called off the GUI thread.
|
|
79
|
+
|
|
80
|
+
The wrapped callable is otherwise unchanged — ``@Slot`` decorators
|
|
81
|
+
can be stacked above or below.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
@wraps(func)
|
|
85
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
86
|
+
# Pass the receiver (first positional, when wrapping a bound method)
|
|
87
|
+
# to assert_gui_thread so its thread() is included in the error.
|
|
88
|
+
receiver = args[0] if args else None
|
|
89
|
+
assert_gui_thread(receiver)
|
|
90
|
+
return func(*args, **kwargs)
|
|
91
|
+
|
|
92
|
+
return wrapper # type: ignore[return-value]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Semantic design tokens, theme registry, and QSS generation."""
|
|
2
|
+
|
|
3
|
+
from lightfall_utils.theming.manager import (
|
|
4
|
+
DARKBLUE_COLORS,
|
|
5
|
+
LIGHT_COLORS,
|
|
6
|
+
SLATE_COLORS,
|
|
7
|
+
BeamlineTheme,
|
|
8
|
+
Theme,
|
|
9
|
+
ThemeColors,
|
|
10
|
+
ThemeManager,
|
|
11
|
+
scaled_pt,
|
|
12
|
+
scaled_px,
|
|
13
|
+
)
|
|
14
|
+
from lightfall_utils.theming.provider import ThemeDefinition, ThemeProvider
|
|
15
|
+
from lightfall_utils.theming.registry import ThemeRegistry
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BeamlineTheme",
|
|
19
|
+
"DARKBLUE_COLORS",
|
|
20
|
+
"LIGHT_COLORS",
|
|
21
|
+
"SLATE_COLORS",
|
|
22
|
+
"Theme",
|
|
23
|
+
"ThemeColors",
|
|
24
|
+
"ThemeDefinition",
|
|
25
|
+
"ThemeManager",
|
|
26
|
+
"ThemeProvider",
|
|
27
|
+
"ThemeRegistry",
|
|
28
|
+
"scaled_pt",
|
|
29
|
+
"scaled_px",
|
|
30
|
+
]
|