oscura 0.10.0__py3-none-any.whl → 0.11.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.
oscura/__init__.py CHANGED
@@ -53,7 +53,7 @@ try:
53
53
  __version__ = version("oscura")
54
54
  except Exception:
55
55
  # Fallback for development/testing when package not installed
56
- __version__ = "0.10.0"
56
+ __version__ = "0.11.0"
57
57
 
58
58
  __author__ = "Oscura Contributors"
59
59
 
oscura/__main__.py CHANGED
@@ -106,6 +106,10 @@ def download_file(url: str, dest: Path, checksum: str | None = None) -> bool:
106
106
  # Create SSL context that works in most environments
107
107
  context = ssl.create_default_context()
108
108
 
109
+ # SEC-003: Validate URL scheme to prevent file:// attacks
110
+ if not url.startswith(("http://", "https://")):
111
+ raise ValueError(f"Unsupported URL scheme (only http/https allowed): {url}")
112
+
109
113
  print(f" Downloading: {url}")
110
114
 
111
115
  with urllib.request.urlopen(url, context=context, timeout=30) as response:
@@ -458,6 +458,12 @@ class MLSignalClassifier:
458
458
  Restores the complete model state including the ML model, feature scaler,
459
459
  feature names, and class labels.
460
460
 
461
+ Warning:
462
+ **Security**: Model files use pickle serialization. Only load model files
463
+ from trusted sources. Loading untrusted model files can execute arbitrary
464
+ code (CWE-502: Deserialization of Untrusted Data). Consider implementing
465
+ HMAC signature verification for production deployments.
466
+
461
467
  Args:
462
468
  path: Path to saved model file.
463
469
 
@@ -17,6 +17,7 @@ References:
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import threading
20
21
  from functools import lru_cache
21
22
  from typing import TYPE_CHECKING, Any, Literal
22
23
 
@@ -32,8 +33,9 @@ if TYPE_CHECKING:
32
33
 
33
34
  from oscura.core.types import MeasurementResult, WaveformTrace
34
35
 
35
- # Global FFT cache statistics
36
+ # Global FFT cache statistics (thread-safe)
36
37
  _fft_cache_stats = {"hits": 0, "misses": 0, "size": 128}
38
+ _fft_cache_lock = threading.Lock()
37
39
 
38
40
 
39
41
  def get_fft_cache_stats() -> dict[str, int]:
@@ -46,7 +48,8 @@ def get_fft_cache_stats() -> dict[str, int]:
46
48
  >>> stats = get_fft_cache_stats()
47
49
  >>> print(f"Cache hit rate: {stats['hits'] / (stats['hits'] + stats['misses']):.1%}")
48
50
  """
49
- return _fft_cache_stats.copy()
51
+ with _fft_cache_lock:
52
+ return _fft_cache_stats.copy()
50
53
 
51
54
 
52
55
  def clear_fft_cache() -> None:
@@ -58,8 +61,9 @@ def clear_fft_cache() -> None:
58
61
  >>> clear_fft_cache() # Clear cached FFT results
59
62
  """
60
63
  _compute_fft_cached.cache_clear()
61
- _fft_cache_stats["hits"] = 0
62
- _fft_cache_stats["misses"] = 0
64
+ with _fft_cache_lock:
65
+ _fft_cache_stats["hits"] = 0
66
+ _fft_cache_stats["misses"] = 0
63
67
 
64
68
 
65
69
  def configure_fft_cache(size: int) -> None:
@@ -72,11 +76,12 @@ def configure_fft_cache(size: int) -> None:
72
76
  >>> configure_fft_cache(256) # Increase cache size for better hit rate
73
77
  """
74
78
  global _compute_fft_cached
75
- _fft_cache_stats["size"] = size
76
- # Recreate cache with new size
77
- _compute_fft_cached = lru_cache(maxsize=size)(_compute_fft_impl)
78
- _fft_cache_stats["hits"] = 0
79
- _fft_cache_stats["misses"] = 0
79
+ with _fft_cache_lock:
80
+ _fft_cache_stats["size"] = size
81
+ # Recreate cache with new size
82
+ _compute_fft_cached = lru_cache(maxsize=size)(_compute_fft_impl)
83
+ _fft_cache_stats["hits"] = 0
84
+ _fft_cache_stats["misses"] = 0
80
85
 
81
86
 
82
87
  def _compute_fft_impl(
@@ -270,7 +275,8 @@ def _fft_cached_path(
270
275
  freq, magnitude_db, phase = _compute_fft_cached(
271
276
  data_bytes, n, window, nfft_computed, detrend, sample_rate
272
277
  )
273
- _fft_cache_stats["hits"] += 1
278
+ with _fft_cache_lock:
279
+ _fft_cache_stats["hits"] += 1
274
280
 
275
281
  if return_phase:
276
282
  return freq, magnitude_db, phase
@@ -302,7 +308,8 @@ def _fft_direct_path(
302
308
  Returns:
303
309
  FFT results (with or without phase).
304
310
  """
305
- _fft_cache_stats["misses"] += 1
311
+ with _fft_cache_lock:
312
+ _fft_cache_stats["misses"] += 1
306
313
 
307
314
  w = get_window(window, n)
308
315
  data_windowed = data_processed * w
@@ -49,7 +49,7 @@ try:
49
49
  __version__ = version("oscura")
50
50
  except Exception:
51
51
  # Fallback for development/testing when package not installed
52
- __version__ = "0.10.0"
52
+ __version__ = "0.11.0"
53
53
 
54
54
  __all__ = [
55
55
  "CANMessage",
@@ -266,12 +266,7 @@
266
266
  "category": "Powertrain",
267
267
  "severity": "High",
268
268
  "system": "Throttle Control",
269
- "possible_causes": [
270
- "TPS circuit shorted to voltage",
271
- "Faulty TPS sensor",
272
- "Wiring harness open",
273
- "ECM problem"
274
- ]
269
+ "possible_causes": ["TPS circuit shorted to voltage", "Faulty TPS sensor", "Wiring harness open", "ECM problem"]
275
270
  },
276
271
  "P0125": {
277
272
  "code": "P0125",
@@ -865,12 +860,7 @@
865
860
  "category": "Powertrain",
866
861
  "severity": "Medium",
867
862
  "system": "Emissions Control",
868
- "possible_causes": [
869
- "EGR valve stuck closed",
870
- "EGR passages clogged",
871
- "Faulty EGR valve",
872
- "Vacuum leak"
873
- ]
863
+ "possible_causes": ["EGR valve stuck closed", "EGR passages clogged", "Faulty EGR valve", "Vacuum leak"]
874
864
  },
875
865
  "P0401": {
876
866
  "code": "P0401",
@@ -891,12 +881,7 @@
891
881
  "category": "Powertrain",
892
882
  "severity": "Medium",
893
883
  "system": "Emissions Control",
894
- "possible_causes": [
895
- "EGR valve stuck open",
896
- "Faulty EGR valve",
897
- "EGR vacuum solenoid fault",
898
- "ECM problem"
899
- ]
884
+ "possible_causes": ["EGR valve stuck open", "Faulty EGR valve", "EGR vacuum solenoid fault", "ECM problem"]
900
885
  },
901
886
  "P0403": {
902
887
  "code": "P0403",
@@ -958,12 +943,7 @@
958
943
  "category": "Powertrain",
959
944
  "severity": "Low",
960
945
  "system": "Emissions Control",
961
- "possible_causes": [
962
- "Loose or missing fuel cap",
963
- "EVAP system leak",
964
- "Faulty purge valve",
965
- "Faulty vent valve"
966
- ]
946
+ "possible_causes": ["Loose or missing fuel cap", "EVAP system leak", "Faulty purge valve", "Faulty vent valve"]
967
947
  },
968
948
  "P0441": {
969
949
  "code": "P0441",
@@ -1075,12 +1055,7 @@
1075
1055
  "category": "Powertrain",
1076
1056
  "severity": "Low",
1077
1057
  "system": "Idle Control",
1078
- "possible_causes": [
1079
- "Vacuum leak",
1080
- "IAC valve fault",
1081
- "Dirty throttle body",
1082
- "PCV valve problem"
1083
- ]
1058
+ "possible_causes": ["Vacuum leak", "IAC valve fault", "Dirty throttle body", "PCV valve problem"]
1084
1059
  },
1085
1060
  "P0507": {
1086
1061
  "code": "P0507",
@@ -1088,12 +1063,7 @@
1088
1063
  "category": "Powertrain",
1089
1064
  "severity": "Low",
1090
1065
  "system": "Idle Control",
1091
- "possible_causes": [
1092
- "Vacuum leak",
1093
- "IAC valve stuck open",
1094
- "PCV valve stuck open",
1095
- "EVAP purge valve leaking"
1096
- ]
1066
+ "possible_causes": ["Vacuum leak", "IAC valve stuck open", "PCV valve stuck open", "EVAP purge valve leaking"]
1097
1067
  },
1098
1068
  "P0600": {
1099
1069
  "code": "P0600",
@@ -1127,12 +1097,7 @@
1127
1097
  "category": "Powertrain",
1128
1098
  "severity": "Critical",
1129
1099
  "system": "Engine Control Module",
1130
- "possible_causes": [
1131
- "ECM not programmed",
1132
- "ECM programming incomplete",
1133
- "Wrong software version",
1134
- "ECM fault"
1135
- ]
1100
+ "possible_causes": ["ECM not programmed", "ECM programming incomplete", "Wrong software version", "ECM fault"]
1136
1101
  },
1137
1102
  "P0603": {
1138
1103
  "code": "P0603",
@@ -1205,12 +1170,7 @@
1205
1170
  "category": "Powertrain",
1206
1171
  "severity": "Medium",
1207
1172
  "system": "Charging System",
1208
- "possible_causes": [
1209
- "Faulty alternator",
1210
- "Wiring harness problem",
1211
- "Poor electrical connection",
1212
- "ECM fault"
1213
- ]
1173
+ "possible_causes": ["Faulty alternator", "Wiring harness problem", "Poor electrical connection", "ECM fault"]
1214
1174
  },
1215
1175
  "P0625": {
1216
1176
  "code": "P0625",
@@ -1283,12 +1243,7 @@
1283
1243
  "category": "Powertrain",
1284
1244
  "severity": "High",
1285
1245
  "system": "Transmission",
1286
- "possible_causes": [
1287
- "Faulty input speed sensor",
1288
- "Wiring harness problem",
1289
- "Sensor reluctor damaged",
1290
- "TCM fault"
1291
- ]
1246
+ "possible_causes": ["Faulty input speed sensor", "Wiring harness problem", "Sensor reluctor damaged", "TCM fault"]
1292
1247
  },
1293
1248
  "P0720": {
1294
1249
  "code": "P0720",
@@ -1491,12 +1446,7 @@
1491
1446
  "category": "Chassis",
1492
1447
  "severity": "High",
1493
1448
  "system": "ABS",
1494
- "possible_causes": [
1495
- "Faulty valve relay",
1496
- "Relay circuit problem",
1497
- "ABS module fault",
1498
- "Wiring harness issue"
1499
- ]
1449
+ "possible_causes": ["Faulty valve relay", "Relay circuit problem", "ABS module fault", "Wiring harness issue"]
1500
1450
  },
1501
1451
  "C0161": {
1502
1452
  "code": "C0161",
@@ -2206,12 +2156,7 @@
2206
2156
  "category": "Body",
2207
2157
  "severity": "Low",
2208
2158
  "system": "Lighting System",
2209
- "possible_causes": [
2210
- "Burned out bulb",
2211
- "Wiring harness problem",
2212
- "Lamp socket corrosion",
2213
- "BCM fault"
2214
- ]
2159
+ "possible_causes": ["Burned out bulb", "Wiring harness problem", "Lamp socket corrosion", "BCM fault"]
2215
2160
  },
2216
2161
  "B0601": {
2217
2162
  "code": "B0601",
@@ -2232,12 +2177,7 @@
2232
2177
  "category": "Body",
2233
2178
  "severity": "Low",
2234
2179
  "system": "Lighting System",
2235
- "possible_causes": [
2236
- "Burned out turn signal bulb",
2237
- "Wiring harness problem",
2238
- "Flasher relay fault",
2239
- "BCM fault"
2240
- ]
2180
+ "possible_causes": ["Burned out turn signal bulb", "Wiring harness problem", "Flasher relay fault", "BCM fault"]
2241
2181
  },
2242
2182
  "B0603": {
2243
2183
  "code": "B0603",
@@ -2245,12 +2185,7 @@
2245
2185
  "category": "Body",
2246
2186
  "severity": "Low",
2247
2187
  "system": "Lighting System",
2248
- "possible_causes": [
2249
- "Burned out turn signal bulb",
2250
- "Wiring harness problem",
2251
- "Flasher relay fault",
2252
- "BCM fault"
2253
- ]
2188
+ "possible_causes": ["Burned out turn signal bulb", "Wiring harness problem", "Flasher relay fault", "BCM fault"]
2254
2189
  },
2255
2190
  "B0604": {
2256
2191
  "code": "B0604",
@@ -2362,12 +2297,7 @@
2362
2297
  "category": "Body",
2363
2298
  "severity": "Low",
2364
2299
  "system": "Keyless Entry",
2365
- "possible_causes": [
2366
- "Key fob battery weak",
2367
- "Key fob not synchronized",
2368
- "BCM fault",
2369
- "Receiver antenna fault"
2370
- ]
2300
+ "possible_causes": ["Key fob battery weak", "Key fob not synchronized", "BCM fault", "Receiver antenna fault"]
2371
2301
  },
2372
2302
  "B1300": {
2373
2303
  "code": "B1300",
@@ -2466,12 +2396,7 @@
2466
2396
  "category": "Network",
2467
2397
  "severity": "Critical",
2468
2398
  "system": "CAN Bus",
2469
- "possible_causes": [
2470
- "TCM not powered",
2471
- "CAN bus wiring problem",
2472
- "TCM internal fault",
2473
- "CAN bus short circuit"
2474
- ]
2399
+ "possible_causes": ["TCM not powered", "CAN bus wiring problem", "TCM internal fault", "CAN bus short circuit"]
2475
2400
  },
2476
2401
  "U0102": {
2477
2402
  "code": "U0102",
@@ -2544,12 +2469,7 @@
2544
2469
  "category": "Network",
2545
2470
  "severity": "High",
2546
2471
  "system": "CAN Bus",
2547
- "possible_causes": [
2548
- "BCM not powered",
2549
- "CAN bus wiring problem",
2550
- "BCM internal fault",
2551
- "Ground connection issue"
2552
- ]
2472
+ "possible_causes": ["BCM not powered", "CAN bus wiring problem", "BCM internal fault", "Ground connection issue"]
2553
2473
  },
2554
2474
  "U0141": {
2555
2475
  "code": "U0141",
@@ -2557,12 +2477,7 @@
2557
2477
  "category": "Network",
2558
2478
  "severity": "High",
2559
2479
  "system": "CAN Bus",
2560
- "possible_causes": [
2561
- "BCM not powered",
2562
- "CAN bus wiring problem",
2563
- "Module internal fault",
2564
- "Connector problem"
2565
- ]
2480
+ "possible_causes": ["BCM not powered", "CAN bus wiring problem", "Module internal fault", "Connector problem"]
2566
2481
  },
2567
2482
  "U0151": {
2568
2483
  "code": "U0151",
@@ -299,7 +299,15 @@ class FIBEXImporter:
299
299
  if not fibex_path.exists():
300
300
  raise FileNotFoundError(f"FIBEX file not found: {fibex_path}")
301
301
 
302
- tree = ET.parse(fibex_path)
302
+ # SEC-004: Protect against XXE attacks by disabling entity expansion
303
+ parser = ET.XMLParser()
304
+ try:
305
+ # Python < 3.12: entity attribute is writable
306
+ parser.entity = {} # type: ignore[misc]
307
+ except AttributeError:
308
+ # Python >= 3.12: entity attribute is read-only, default behavior is safe
309
+ pass
310
+ tree = ET.parse(fibex_path, parser=parser)
303
311
  root = tree.getroot()
304
312
 
305
313
  # Extract cluster configuration
@@ -149,20 +149,14 @@
149
149
  "type": "array",
150
150
  "description": "Device IDs to include (whitelist)",
151
151
  "items": {
152
- "oneOf": [
153
- { "type": "integer" },
154
- { "type": "string", "pattern": "^0[xX][0-9A-Fa-f]+$" }
155
- ]
152
+ "oneOf": [{ "type": "integer" }, { "type": "string", "pattern": "^0[xX][0-9A-Fa-f]+$" }]
156
153
  }
157
154
  },
158
155
  "exclude_devices": {
159
156
  "type": "array",
160
157
  "description": "Device IDs to exclude (blacklist)",
161
158
  "items": {
162
- "oneOf": [
163
- { "type": "integer" },
164
- { "type": "string", "pattern": "^0[xX][0-9A-Fa-f]+$" }
165
- ]
159
+ "oneOf": [{ "type": "integer" }, { "type": "string", "pattern": "^0[xX][0-9A-Fa-f]+$" }]
166
160
  }
167
161
  },
168
162
  "include_categories": {
@@ -118,10 +118,7 @@
118
118
  },
119
119
  "value": {
120
120
  "description": "Expected constant value for validation",
121
- "oneOf": [
122
- { "type": "integer" },
123
- { "type": "array", "items": { "type": "integer" } }
124
- ]
121
+ "oneOf": [{ "type": "integer" }, { "type": "array", "items": { "type": "integer" } }]
125
122
  },
126
123
  "description": {
127
124
  "type": "string",
@@ -188,18 +185,7 @@
188
185
  },
189
186
  "type": {
190
187
  "type": "string",
191
- "enum": [
192
- "uint8",
193
- "uint16",
194
- "uint32",
195
- "uint64",
196
- "int8",
197
- "int16",
198
- "int32",
199
- "int64",
200
- "float32",
201
- "float64"
202
- ],
188
+ "enum": ["uint8", "uint16", "uint32", "uint64", "int8", "int16", "int32", "int64", "float32", "float64"],
203
189
  "description": "Sample data type"
204
190
  },
205
191
  "endian": {
@@ -303,10 +289,7 @@
303
289
  },
304
290
  "expected": {
305
291
  "description": "Expected value",
306
- "oneOf": [
307
- { "type": "integer" },
308
- { "type": "array", "items": { "type": "integer" } }
309
- ]
292
+ "oneOf": [{ "type": "integer" }, { "type": "array", "items": { "type": "integer" } }]
310
293
  },
311
294
  "on_failure": {
312
295
  "type": "string",
@@ -379,10 +362,7 @@
379
362
  },
380
363
  "pattern": {
381
364
  "description": "Idle pattern to detect",
382
- "oneOf": [
383
- { "type": "string", "enum": ["auto", "zeros", "ones"] },
384
- { "type": "integer" }
385
- ]
365
+ "oneOf": [{ "type": "string", "enum": ["auto", "zeros", "ones"] }, { "type": "integer" }]
386
366
  },
387
367
  "min_duration": {
388
368
  "type": "integer",
@@ -241,12 +241,7 @@
241
241
  },
242
242
  "value": {
243
243
  "description": "Expected constant value for validation",
244
- "oneOf": [
245
- { "type": "integer" },
246
- { "type": "number" },
247
- { "type": "string" },
248
- { "type": "array" }
249
- ]
244
+ "oneOf": [{ "type": "integer" }, { "type": "number" }, { "type": "string" }, { "type": "array" }]
250
245
  },
251
246
  "condition": {
252
247
  "type": "string",
@@ -331,12 +326,7 @@
331
326
  },
332
327
  "expected": {
333
328
  "description": "Expected value",
334
- "oneOf": [
335
- { "type": "integer" },
336
- { "type": "number" },
337
- { "type": "string" },
338
- { "type": "array" }
339
- ]
329
+ "oneOf": [{ "type": "integer" }, { "type": "number" }, { "type": "string" }, { "type": "array" }]
340
330
  },
341
331
  "on_mismatch": {
342
332
  "type": "string",
@@ -475,24 +475,31 @@ class PacketValidator:
475
475
 
476
476
  @staticmethod
477
477
  def _crc32(data: bytes, poly: int = 0xEDB88320) -> int:
478
- """Compute CRC-32 checksum.
478
+ """Compute CRC-32 checksum using native implementation.
479
479
 
480
480
  Args:
481
481
  data: Data to checksum.
482
482
  poly: CRC polynomial (default: 0xEDB88320 for CRC-32).
483
+ Note: Only standard CRC-32 polynomial is supported by native implementation.
483
484
 
484
485
  Returns:
485
486
  CRC-32 value.
487
+
488
+ Note:
489
+ Uses zlib.crc32() for performance (~100x faster than pure Python).
490
+ Custom polynomials are not supported - raises ValueError if non-standard poly provided.
486
491
  """
487
- crc = 0xFFFFFFFF
488
- for byte in data:
489
- crc ^= byte
490
- for _ in range(8):
491
- if crc & 1:
492
- crc = (crc >> 1) ^ poly
493
- else:
494
- crc >>= 1
495
- return crc ^ 0xFFFFFFFF
492
+ import zlib
493
+
494
+ # Verify standard CRC-32 polynomial (zlib only supports this)
495
+ if poly != 0xEDB88320:
496
+ raise ValueError(
497
+ f"Non-standard CRC polynomial {poly:#x} not supported by native implementation. "
498
+ "Only standard CRC-32 (0xEDB88320) is available."
499
+ )
500
+
501
+ # zlib.crc32 returns signed int on some platforms, mask to unsigned
502
+ return zlib.crc32(data) & 0xFFFFFFFF
496
503
 
497
504
  def get_statistics(self) -> ValidationStats:
498
505
  """Get aggregate validation statistics.
oscura/sessions/legacy.py CHANGED
@@ -17,6 +17,7 @@ import gzip
17
17
  import hashlib
18
18
  import hmac
19
19
  import pickle
20
+ import secrets
20
21
  from dataclasses import dataclass, field
21
22
  from datetime import datetime
22
23
  from enum import Enum
@@ -28,7 +29,45 @@ from oscura.core.exceptions import SecurityError
28
29
  # Session file format constants
29
30
  _SESSION_MAGIC = b"OSC1" # Magic bytes for new format with signature
30
31
  _SESSION_SIGNATURE_SIZE = 32 # SHA256 hash size in bytes
31
- _SECURITY_KEY = hashlib.sha256(b"oscura-session-v1").digest()
32
+
33
+
34
+ def _get_security_key() -> bytes:
35
+ """Get or generate per-installation session security key.
36
+
37
+ The key is generated once per installation and stored in ~/.oscura/session_key
38
+ with restrictive permissions (0o600). This provides better security than a
39
+ shared hardcoded key.
40
+
41
+ Returns:
42
+ 32-byte security key for HMAC signing.
43
+ """
44
+ key_file = Path.home() / ".oscura" / "session_key"
45
+
46
+ if key_file.exists():
47
+ # Load existing key
48
+ try:
49
+ return key_file.read_bytes()
50
+ except (OSError, PermissionError):
51
+ # Fall back to generating new key if can't read
52
+ pass
53
+
54
+ # Generate new random key
55
+ key_file.parent.mkdir(parents=True, exist_ok=True)
56
+ key = secrets.token_bytes(32)
57
+
58
+ # Write with restrictive permissions
59
+ try:
60
+ key_file.write_bytes(key)
61
+ key_file.chmod(0o600) # Owner read/write only
62
+ except (OSError, PermissionError):
63
+ # Can't write key file - continue with ephemeral key
64
+ # This happens in read-only filesystems or restricted environments
65
+ pass
66
+
67
+ return key
68
+
69
+
70
+ _SECURITY_KEY = _get_security_key()
32
71
 
33
72
 
34
73
  class AnnotationType(Enum):
@@ -709,6 +748,15 @@ class Session:
709
748
  def load_session(path: str | Path) -> Session:
710
749
  """Load session from file.
711
750
 
751
+ This function implements HMAC-SHA256 signature verification before deserializing
752
+ session data to protect against tampering and malicious file modifications.
753
+
754
+ Security:
755
+ Session files are protected with HMAC-SHA256 signatures. Only load session
756
+ files from trusted sources. While HMAC verification prevents tampering,
757
+ the shared security key means all installations can verify each other's
758
+ files. Consider using per-installation keys for sensitive deployments.
759
+
712
760
  Args:
713
761
  path: Path to session file (.tks).
714
762
 
@@ -339,7 +339,11 @@ def _create_metric_plot(
339
339
  plot_file.parent.mkdir(parents=True, exist_ok=True)
340
340
  plt.savefig(plot_file)
341
341
  else:
342
- plt.show()
342
+ # Try to show, but gracefully handle non-interactive backends
343
+ try:
344
+ plt.show()
345
+ except Exception:
346
+ pass # Silently skip if backend doesn't support interactive display
343
347
 
344
348
 
345
349
  def _plot_histogram(