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,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()
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
+ ]