mmrelay 1.2.6__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.
Files changed (50) hide show
  1. mmrelay/__init__.py +5 -0
  2. mmrelay/__main__.py +29 -0
  3. mmrelay/cli.py +2013 -0
  4. mmrelay/cli_utils.py +746 -0
  5. mmrelay/config.py +956 -0
  6. mmrelay/constants/__init__.py +65 -0
  7. mmrelay/constants/app.py +29 -0
  8. mmrelay/constants/config.py +78 -0
  9. mmrelay/constants/database.py +22 -0
  10. mmrelay/constants/formats.py +20 -0
  11. mmrelay/constants/messages.py +45 -0
  12. mmrelay/constants/network.py +45 -0
  13. mmrelay/constants/plugins.py +42 -0
  14. mmrelay/constants/queue.py +20 -0
  15. mmrelay/db_runtime.py +269 -0
  16. mmrelay/db_utils.py +1017 -0
  17. mmrelay/e2ee_utils.py +400 -0
  18. mmrelay/log_utils.py +274 -0
  19. mmrelay/main.py +439 -0
  20. mmrelay/matrix_utils.py +3091 -0
  21. mmrelay/meshtastic_utils.py +1245 -0
  22. mmrelay/message_queue.py +647 -0
  23. mmrelay/plugin_loader.py +1933 -0
  24. mmrelay/plugins/__init__.py +3 -0
  25. mmrelay/plugins/base_plugin.py +638 -0
  26. mmrelay/plugins/debug_plugin.py +30 -0
  27. mmrelay/plugins/drop_plugin.py +127 -0
  28. mmrelay/plugins/health_plugin.py +64 -0
  29. mmrelay/plugins/help_plugin.py +79 -0
  30. mmrelay/plugins/map_plugin.py +353 -0
  31. mmrelay/plugins/mesh_relay_plugin.py +222 -0
  32. mmrelay/plugins/nodes_plugin.py +92 -0
  33. mmrelay/plugins/ping_plugin.py +128 -0
  34. mmrelay/plugins/telemetry_plugin.py +179 -0
  35. mmrelay/plugins/weather_plugin.py +312 -0
  36. mmrelay/runtime_utils.py +35 -0
  37. mmrelay/setup_utils.py +828 -0
  38. mmrelay/tools/__init__.py +27 -0
  39. mmrelay/tools/mmrelay.service +19 -0
  40. mmrelay/tools/sample-docker-compose-prebuilt.yaml +30 -0
  41. mmrelay/tools/sample-docker-compose.yaml +30 -0
  42. mmrelay/tools/sample.env +10 -0
  43. mmrelay/tools/sample_config.yaml +120 -0
  44. mmrelay/windows_utils.py +346 -0
  45. mmrelay-1.2.6.dist-info/METADATA +145 -0
  46. mmrelay-1.2.6.dist-info/RECORD +50 -0
  47. mmrelay-1.2.6.dist-info/WHEEL +5 -0
  48. mmrelay-1.2.6.dist-info/entry_points.txt +2 -0
  49. mmrelay-1.2.6.dist-info/licenses/LICENSE +675 -0
  50. mmrelay-1.2.6.dist-info/top_level.txt +1 -0
mmrelay/e2ee_utils.py ADDED
@@ -0,0 +1,400 @@
1
+ """
2
+ Centralized E2EE (End-to-End Encryption) utilities for consistent status detection and messaging.
3
+
4
+ This module provides a unified approach to E2EE status detection, warning messages, and room
5
+ formatting across all components of the meshtastic-matrix-relay application.
6
+ """
7
+
8
+ import importlib
9
+ import os
10
+ import sys
11
+ from typing import Any, Dict, List, Literal, Optional, TypedDict
12
+
13
+ from mmrelay.cli_utils import get_command
14
+ from mmrelay.constants.app import (
15
+ CREDENTIALS_FILENAME,
16
+ PACKAGE_NAME_E2E,
17
+ PYTHON_OLM_PACKAGE,
18
+ WINDOWS_PLATFORM,
19
+ )
20
+
21
+
22
+ class E2EEStatus(TypedDict):
23
+ """Type definition for E2EE status dictionary."""
24
+
25
+ enabled: bool
26
+ available: bool
27
+ configured: bool
28
+ platform_supported: bool
29
+ dependencies_installed: bool
30
+ credentials_available: bool
31
+ overall_status: Literal["ready", "disabled", "unavailable", "incomplete", "unknown"]
32
+ issues: List[str]
33
+
34
+
35
+ def get_e2ee_status(
36
+ config: Dict[str, Any], config_path: Optional[str] = None
37
+ ) -> E2EEStatus:
38
+ """
39
+ Return a consolidated E2EE status summary by inspecting the runtime platform, required crypto dependencies, configuration, and presence of Matrix credentials.
40
+
41
+ This inspects:
42
+ - platform support (disables on Windows/msys/cygwin),
43
+ - presence of Python olm/nio components,
44
+ - whether E2EE is enabled in the provided config (supports legacy `matrix.encryption.enabled`),
45
+ - whether Matrix credentials (credentials.json) can be found (uses config_path directory if provided, otherwise falls back to the application's base directory).
46
+
47
+ Parameters:
48
+ config (Dict[str, Any]): Parsed application configuration; used to read `matrix.e2ee.enabled` (and legacy `matrix.encryption.enabled`).
49
+ config_path (Optional[str]): Optional path to the configuration file directory to prioritize when checking for credentials.json.
50
+
51
+ Returns:
52
+ E2EEStatus: A dict with the following keys:
53
+ - enabled (bool): E2EE enabled in configuration.
54
+ - available (bool): Platform + dependencies allow E2EE.
55
+ - configured (bool): Authentication/credentials are present.
56
+ - platform_supported (bool): True unless running on Windows/msys/cygwin.
57
+ - dependencies_installed (bool): True if required olm/nio components are importable.
58
+ - credentials_available (bool): True if credentials.json is discovered.
59
+ - overall_status (str): One of "ready", "disabled", "unavailable", "incomplete", or "unknown".
60
+ - issues (List[str]): Human-readable issues found that prevent full E2EE readiness.
61
+ """
62
+ status: E2EEStatus = {
63
+ "enabled": False,
64
+ "available": False,
65
+ "configured": False,
66
+ "platform_supported": True,
67
+ "dependencies_installed": False,
68
+ "credentials_available": False,
69
+ "overall_status": "unknown",
70
+ "issues": [],
71
+ }
72
+
73
+ # Check platform support
74
+ if sys.platform == WINDOWS_PLATFORM or sys.platform.startswith(("msys", "cygwin")):
75
+ status["platform_supported"] = False
76
+ status["issues"].append("E2EE is not supported on Windows")
77
+
78
+ # Check dependencies
79
+ try:
80
+ importlib.import_module("olm")
81
+
82
+ if os.getenv("MMRELAY_TESTING") != "1":
83
+ nio_crypto = importlib.import_module("nio.crypto")
84
+ if not hasattr(nio_crypto, "OlmDevice"):
85
+ raise ImportError("nio.crypto.OlmDevice is unavailable")
86
+
87
+ nio_store = importlib.import_module("nio.store")
88
+ if not hasattr(nio_store, "SqliteStore"):
89
+ raise ImportError("nio.store.SqliteStore is unavailable")
90
+
91
+ status["dependencies_installed"] = True
92
+ except ImportError:
93
+ status["dependencies_installed"] = False
94
+ status["issues"].append(
95
+ f"E2EE dependencies not installed ({PYTHON_OLM_PACKAGE})"
96
+ )
97
+
98
+ # Check configuration
99
+ matrix_section = config.get("matrix", {})
100
+ e2ee_config = matrix_section.get("e2ee", {})
101
+ encryption_config = matrix_section.get("encryption", {}) # Legacy support
102
+ status["enabled"] = e2ee_config.get("enabled", False) or encryption_config.get(
103
+ "enabled", False
104
+ )
105
+
106
+ if not status["enabled"]:
107
+ status["issues"].append("E2EE is disabled in configuration")
108
+
109
+ # Check credentials
110
+ if config_path:
111
+ status["credentials_available"] = _check_credentials_available(config_path)
112
+ else:
113
+ # Fallback to base directory check only
114
+ from mmrelay.config import get_base_dir
115
+
116
+ base_credentials_path = os.path.join(get_base_dir(), CREDENTIALS_FILENAME)
117
+ status["credentials_available"] = os.path.exists(base_credentials_path)
118
+
119
+ if not status["credentials_available"]:
120
+ status["issues"].append("Matrix authentication not configured")
121
+
122
+ # Determine overall availability and status
123
+ status["available"] = (
124
+ status["platform_supported"] and status["dependencies_installed"]
125
+ )
126
+ status["configured"] = status["credentials_available"]
127
+
128
+ # Determine overall status
129
+ if not status["platform_supported"]:
130
+ status["overall_status"] = "unavailable"
131
+ elif status["enabled"] and status["available"] and status["configured"]:
132
+ status["overall_status"] = "ready"
133
+ elif not status["enabled"]:
134
+ status["overall_status"] = "disabled"
135
+ else:
136
+ status["overall_status"] = "incomplete"
137
+
138
+ return status
139
+
140
+
141
+ def _check_credentials_available(config_path: str) -> bool:
142
+ """
143
+ Check whether the Matrix credentials file exists in standard locations.
144
+
145
+ Searches for CREDENTIALS_FILENAME in the directory containing the provided configuration file first, then falls back to the application's base directory (via mmrelay.config.get_base_dir()). If the base directory cannot be resolved (ImportError or OSError), the function returns False.
146
+
147
+ Parameters:
148
+ config_path (str): Filesystem path to the configuration file whose directory should be checked.
149
+
150
+ Returns:
151
+ bool: True if the credentials file exists in either the config directory or the base directory; otherwise False.
152
+ """
153
+ # Check config directory first
154
+ config_dir = os.path.dirname(config_path)
155
+ config_credentials_path = os.path.join(config_dir, CREDENTIALS_FILENAME)
156
+
157
+ if os.path.exists(config_credentials_path):
158
+ return True
159
+
160
+ # Fallback to base directory
161
+ try:
162
+ from mmrelay.config import get_base_dir
163
+
164
+ base_credentials_path = os.path.join(get_base_dir(), CREDENTIALS_FILENAME)
165
+ return os.path.exists(base_credentials_path)
166
+ except (ImportError, OSError):
167
+ # If we can't determine base directory, assume no credentials
168
+ return False
169
+
170
+
171
+ def get_room_encryption_warnings(
172
+ rooms: Dict[str, Any], e2ee_status: Dict[str, Any]
173
+ ) -> List[str]:
174
+ """
175
+ Return user-facing warnings for encrypted rooms when E2EE is not fully ready.
176
+
177
+ If the provided E2EE status has overall_status == "ready", returns an empty list.
178
+ Scans the given rooms mapping for items whose `encrypted` attribute is truthy and
179
+ produces one or two warning lines per situation:
180
+ - A line noting how many encrypted rooms were detected and the reason (platform unsupported,
181
+ disabled, or incomplete).
182
+ - A follow-up line indicating whether messages to those rooms will be blocked or may be blocked.
183
+
184
+ Parameters:
185
+ rooms: Mapping of room_id -> room object. Room objects are expected to expose
186
+ an `encrypted` attribute and optionally a `display_name` attribute; room_id is
187
+ used as a fallback name.
188
+ e2ee_status: E2EE status dictionary as returned by get_e2ee_status(); this function
189
+ reads the `overall_status` key to decide warning text.
190
+
191
+ Returns:
192
+ List[str]: Formatted warning lines (empty if no relevant warnings).
193
+ """
194
+ warnings = []
195
+
196
+ if e2ee_status["overall_status"] == "ready":
197
+ # No warnings needed when E2EE is fully ready
198
+ return warnings
199
+
200
+ # Check for encrypted rooms
201
+ encrypted_rooms = []
202
+
203
+ # Handle invalid rooms input
204
+ if not rooms or not hasattr(rooms, "items"):
205
+ return warnings
206
+
207
+ for room_id, room in rooms.items():
208
+ if getattr(room, "encrypted", False):
209
+ room_name = getattr(room, "display_name", room_id)
210
+ encrypted_rooms.append(room_name)
211
+
212
+ if encrypted_rooms:
213
+ overall = e2ee_status["overall_status"]
214
+ if overall == "unavailable":
215
+ warnings.append(
216
+ f"⚠️ {len(encrypted_rooms)} encrypted room(s) detected but E2EE is not supported on Windows"
217
+ )
218
+ elif overall == "disabled":
219
+ warnings.append(
220
+ f"⚠️ {len(encrypted_rooms)} encrypted room(s) detected but E2EE is disabled"
221
+ )
222
+ else:
223
+ warnings.append(
224
+ f"⚠️ {len(encrypted_rooms)} encrypted room(s) detected but E2EE setup is incomplete"
225
+ )
226
+
227
+ # Tail message depends on readiness
228
+ if overall == "incomplete":
229
+ warnings.append(" Messages to encrypted rooms may be blocked")
230
+ else:
231
+ warnings.append(" Messages to encrypted rooms will be blocked")
232
+
233
+ return warnings
234
+
235
+
236
+ def format_room_list(rooms: Dict[str, Any], e2ee_status: Dict[str, Any]) -> List[str]:
237
+ """
238
+ Format a list of human-readable room lines with encryption indicators and status-specific warnings.
239
+
240
+ Given a mapping of room_id -> room-like objects, produce one display string per room:
241
+ - If E2EE overall_status == "ready": encrypted rooms are marked "🔒 {name} - Encrypted"; non-encrypted rooms are "✅ {name}".
242
+ - If not ready: encrypted rooms are prefixed with "⚠️" and include a short reason derived from overall_status ("unavailable" -> not supported on Windows, "disabled" -> disabled in config, otherwise "incomplete"); non-encrypted rooms remain "✅ {name}".
243
+
244
+ Parameters:
245
+ rooms: Mapping of room_id to a room-like object. Each room may have attributes:
246
+ - display_name (str): human-friendly name (fallback: room_id)
247
+ - encrypted (bool): whether the room is encrypted (default: False)
248
+ e2ee_status: E2EE status dictionary (as returned by get_e2ee_status()). Only e2ee_status["overall_status"] is used.
249
+
250
+ Returns:
251
+ List[str]: One formatted line per room suitable for user display.
252
+ """
253
+ room_lines = []
254
+
255
+ # Handle invalid rooms input
256
+ if not rooms or not hasattr(rooms, "items"):
257
+ return room_lines
258
+
259
+ for room_id, room in rooms.items():
260
+ room_name = getattr(room, "display_name", room_id)
261
+ encrypted = getattr(room, "encrypted", False)
262
+
263
+ if e2ee_status["overall_status"] == "ready":
264
+ # Show detailed status when E2EE is fully ready
265
+ if encrypted:
266
+ room_lines.append(f" 🔒 {room_name} - Encrypted")
267
+ else:
268
+ room_lines.append(f" ✅ {room_name}")
269
+ else:
270
+ # Show warnings for encrypted rooms when E2EE is not ready
271
+ if encrypted:
272
+ if e2ee_status["overall_status"] == "unavailable":
273
+ room_lines.append(
274
+ f" ⚠️ {room_name} - Encrypted (E2EE not supported on Windows - messages will be blocked)"
275
+ )
276
+ elif e2ee_status["overall_status"] == "disabled":
277
+ room_lines.append(
278
+ f" ⚠️ {room_name} - Encrypted (E2EE disabled - messages will be blocked)"
279
+ )
280
+ else:
281
+ room_lines.append(
282
+ f" ⚠️ {room_name} - Encrypted (E2EE incomplete - messages may be blocked)"
283
+ )
284
+ else:
285
+ room_lines.append(f" ✅ {room_name}")
286
+
287
+ return room_lines
288
+
289
+
290
+ # Standard warning message templates
291
+ def get_e2ee_warning_messages():
292
+ """
293
+ Return a mapping of standard user-facing E2EE warning messages.
294
+
295
+ Each key is a short status identifier and the value is a ready-to-display message. Messages that reference external tooling or packages are rendered with the module's constants and CLI commands (e.g. PACKAGE_NAME_E2E and get_command).
296
+ Returns:
297
+ dict: Mapping of status keys to formatted warning strings. Keys include:
298
+ - "unavailable", "disabled", "incomplete", "missing_deps",
299
+ "missing_auth", and "missing_config".
300
+ """
301
+ return {
302
+ "unavailable": "E2EE is not supported on Windows - messages to encrypted rooms will be blocked",
303
+ "disabled": "E2EE is disabled in configuration - messages to encrypted rooms will be blocked",
304
+ "incomplete": "E2EE setup is incomplete - messages to encrypted rooms may be blocked",
305
+ "missing_deps": f"E2EE dependencies not installed - run: pipx install {PACKAGE_NAME_E2E}",
306
+ "missing_auth": f"Matrix authentication not configured - run: {get_command('auth_login')}",
307
+ "missing_config": "E2EE not enabled in configuration - add 'e2ee: enabled: true' under matrix section",
308
+ }
309
+
310
+
311
+ def get_e2ee_error_message(e2ee_status: Dict[str, Any]) -> str:
312
+ """
313
+ Return a single user-facing E2EE error message based on the provided E2EE status.
314
+
315
+ If the status is "ready" this returns an empty string. Otherwise selects one actionable
316
+ message (in priority order) for the first failing condition:
317
+ 1. platform not supported
318
+ 2. E2EE disabled in config
319
+ 3. missing E2EE dependencies
320
+ 4. missing Matrix credentials
321
+ 5. otherwise, E2EE setup incomplete
322
+
323
+ Parameters:
324
+ e2ee_status (dict): Status dictionary produced by get_e2ee_status().
325
+ Expected keys used: "overall_status", "platform_supported", "enabled",
326
+ "dependencies_installed", and "credentials_available".
327
+
328
+ Returns:
329
+ str: A single formatted warning/instruction string, or an empty string when ready.
330
+ """
331
+ if e2ee_status.get("overall_status") == "ready":
332
+ return "" # No error
333
+
334
+ # Get current warning messages
335
+ warning_messages = get_e2ee_warning_messages()
336
+
337
+ # Build error message based on specific issues
338
+ if not e2ee_status.get("platform_supported", True):
339
+ return warning_messages["unavailable"]
340
+ elif not e2ee_status.get("enabled", False):
341
+ return warning_messages["disabled"]
342
+ elif not e2ee_status.get("dependencies_installed", False):
343
+ return warning_messages["missing_deps"]
344
+ elif not e2ee_status.get("credentials_available", False):
345
+ return warning_messages["missing_auth"]
346
+ else:
347
+ return warning_messages["incomplete"]
348
+
349
+
350
+ def get_e2ee_fix_instructions(e2ee_status: Dict[str, Any]) -> List[str]:
351
+ """
352
+ Return ordered, user-facing instructions to resolve E2EE setup problems.
353
+
354
+ If E2EE is already ready, returns a single confirmation line. If the platform is unsupported,
355
+ returns platform-specific guidance. Otherwise returns a numbered sequence of actionable steps
356
+ (as separate list lines) to install required E2EE dependencies, provision Matrix credentials,
357
+ enable E2EE in the configuration, and finally verify the configuration. Command and config
358
+ snippets appear as indented lines in the returned list.
359
+
360
+ Parameters:
361
+ e2ee_status (dict): Status mapping produced by get_e2ee_status(). The function reads the
362
+ following keys to decide which steps to include: "overall_status",
363
+ "platform_supported", "dependencies_installed", "credentials_available", and "enabled".
364
+
365
+ Returns:
366
+ List[str]: Ordered, human-readable instruction lines. Each step is a separate string;
367
+ related commands or configuration snippets are returned as additional indented strings.
368
+ """
369
+ if e2ee_status["overall_status"] == "ready":
370
+ return ["✅ E2EE is fully configured and ready"]
371
+
372
+ instructions = []
373
+
374
+ if not e2ee_status["platform_supported"]:
375
+ instructions.append("❌ E2EE is not supported on Windows")
376
+ instructions.append(" Use Linux or macOS for E2EE support")
377
+ return instructions
378
+
379
+ step = 1
380
+ if not e2ee_status["dependencies_installed"]:
381
+ instructions.append(f"{step}. Install E2EE dependencies:")
382
+ instructions.append(f" pipx install {PACKAGE_NAME_E2E}")
383
+ step += 1
384
+
385
+ if not e2ee_status["credentials_available"]:
386
+ instructions.append(f"{step}. Set up Matrix authentication:")
387
+ instructions.append(f" {get_command('auth_login')}")
388
+ step += 1
389
+
390
+ if not e2ee_status["enabled"]:
391
+ instructions.append(f"{step}. Enable E2EE in configuration:")
392
+ instructions.append(" Edit config.yaml and add under matrix section:")
393
+ instructions.append(" e2ee:")
394
+ instructions.append(" enabled: true")
395
+ step += 1
396
+
397
+ instructions.append(f"{step}. Verify configuration:")
398
+ instructions.append(f" {get_command('check_config')}")
399
+
400
+ return instructions
mmrelay/log_utils.py ADDED
@@ -0,0 +1,274 @@
1
+ import logging
2
+ from logging.handlers import RotatingFileHandler
3
+
4
+ # Import Rich components only when not running as a service
5
+ try:
6
+ from mmrelay.runtime_utils import is_running_as_service
7
+
8
+ if not is_running_as_service():
9
+ from rich.console import Console
10
+ from rich.logging import RichHandler
11
+
12
+ RICH_AVAILABLE = True
13
+ else:
14
+ RICH_AVAILABLE = False
15
+ except ImportError:
16
+ RICH_AVAILABLE = False
17
+
18
+ # Import parse_arguments only when needed to avoid conflicts with pytest
19
+ from mmrelay.config import get_log_dir
20
+ from mmrelay.constants.app import APP_DISPLAY_NAME
21
+ from mmrelay.constants.messages import (
22
+ DEFAULT_LOG_BACKUP_COUNT,
23
+ DEFAULT_LOG_SIZE_MB,
24
+ LOG_SIZE_BYTES_MULTIPLIER,
25
+ )
26
+
27
+ # Initialize Rich console only if available
28
+ console = Console() if RICH_AVAILABLE else None
29
+
30
+ # Define custom log level styles - not used directly but kept for reference
31
+ # Rich 14.0.0+ supports level_styles parameter, but we're using an approach
32
+ # that works with older versions too
33
+ LOG_LEVEL_STYLES = {
34
+ "DEBUG": "dim blue",
35
+ "INFO": "green",
36
+ "WARNING": "yellow",
37
+ "ERROR": "bold red",
38
+ "CRITICAL": "bold white on red",
39
+ }
40
+
41
+ # Global config variable that will be set from main.py
42
+ config = None
43
+
44
+ # Global variable to store the log file path
45
+ log_file_path = None
46
+
47
+ # Track if component debug logging has been configured
48
+ _component_debug_configured = False
49
+
50
+ # Component logger mapping for data-driven configuration
51
+ _COMPONENT_LOGGERS = {
52
+ "matrix_nio": [
53
+ "nio",
54
+ "nio.client",
55
+ "nio.http",
56
+ "nio.crypto",
57
+ "nio.responses",
58
+ "nio.rooms",
59
+ ],
60
+ "bleak": ["bleak", "bleak.backends"],
61
+ "meshtastic": [
62
+ "meshtastic",
63
+ "meshtastic.serial_interface",
64
+ "meshtastic.tcp_interface",
65
+ "meshtastic.ble_interface",
66
+ ],
67
+ }
68
+
69
+
70
+ def configure_component_debug_logging():
71
+ """
72
+ Configure log levels and handlers for external component loggers based on config.
73
+
74
+ Reads `config["logging"]["debug"]` and for each component:
75
+ - If enabled (True or a valid log level string), sets the component's loggers to the specified level and attaches the main application's handlers to them. This makes component logs appear in the console and log file.
76
+ - If disabled (falsy or missing), silences the component by setting its loggers to a level higher than CRITICAL.
77
+
78
+ This function runs only once. It is not thread-safe and should be called early in the application startup, after the main logger is configured but before other modules are imported.
79
+ """
80
+ global _component_debug_configured, config
81
+
82
+ # Only configure once
83
+ if _component_debug_configured or config is None:
84
+ return
85
+
86
+ # Get the main application logger and its handlers to attach to component loggers
87
+ main_logger = logging.getLogger(APP_DISPLAY_NAME)
88
+ main_handlers = main_logger.handlers
89
+ debug_settings = config.get("logging", {}).get("debug")
90
+
91
+ # Ensure debug_config is a dictionary, handling malformed configs gracefully
92
+ if isinstance(debug_settings, dict):
93
+ debug_config = debug_settings
94
+ else:
95
+ if debug_settings is not None:
96
+ main_logger.warning(
97
+ "Debug logging section is not a dictionary. "
98
+ "All component debug logging will be disabled. "
99
+ "Check your config.yaml debug section formatting."
100
+ )
101
+ debug_config = {}
102
+
103
+ for component, loggers in _COMPONENT_LOGGERS.items():
104
+ component_config = debug_config.get(component)
105
+
106
+ if component_config:
107
+ # Component debug is enabled - check if it's a boolean or a log level
108
+ if isinstance(component_config, bool):
109
+ # Legacy boolean format - default to DEBUG
110
+ log_level = logging.DEBUG
111
+ elif isinstance(component_config, str):
112
+ # String log level format (e.g., "warning", "error", "debug")
113
+ try:
114
+ log_level = getattr(logging, component_config.upper())
115
+ except AttributeError:
116
+ # Invalid log level, fall back to DEBUG
117
+ log_level = logging.DEBUG
118
+ else:
119
+ # Invalid config, fall back to DEBUG
120
+ log_level = logging.DEBUG
121
+
122
+ # Configure all loggers for this component
123
+ for logger_name in loggers:
124
+ component_logger = logging.getLogger(logger_name)
125
+ component_logger.setLevel(log_level)
126
+ component_logger.propagate = False # Prevent duplicate logging
127
+ # Attach main handlers to the component logger
128
+ for handler in main_handlers:
129
+ if handler not in component_logger.handlers:
130
+ component_logger.addHandler(handler)
131
+ else:
132
+ # Component debug is disabled - completely suppress external library logging
133
+ # Use a level higher than CRITICAL to effectively disable all messages
134
+ for logger_name in loggers:
135
+ logging.getLogger(logger_name).setLevel(logging.CRITICAL + 1)
136
+
137
+ _component_debug_configured = True
138
+
139
+
140
+ def get_logger(name):
141
+ """
142
+ Create and configure a logger with console output (optionally colorized) and optional rotating file logging.
143
+
144
+ The logger's log level, colorization, and file logging behavior are determined by global configuration and command-line arguments. Log files are rotated by size, and the log directory is created if necessary. If the logger name matches the application display name, the log file path is stored globally for reference.
145
+
146
+ Parameters:
147
+ name (str): The name of the logger to create.
148
+
149
+ Returns:
150
+ logging.Logger: The configured logger instance.
151
+ """
152
+ logger = logging.getLogger(name=name)
153
+
154
+ # Default to INFO level if config is not available
155
+ log_level = logging.INFO
156
+ color_enabled = True # Default to using colors
157
+
158
+ # Try to get log level and color settings from config
159
+ global config
160
+ if config is not None and "logging" in config:
161
+ if "level" in config["logging"]:
162
+ try:
163
+ log_level = getattr(logging, config["logging"]["level"].upper())
164
+ except AttributeError:
165
+ # Invalid log level, fall back to default
166
+ log_level = logging.INFO
167
+ # Check if colors should be disabled
168
+ if "color_enabled" in config["logging"]:
169
+ color_enabled = config["logging"]["color_enabled"]
170
+
171
+ logger.setLevel(log_level)
172
+ logger.propagate = False
173
+
174
+ # Check if logger already has handlers to avoid duplicates
175
+ if logger.handlers:
176
+ return logger
177
+
178
+ # Add handler for console logging (with or without colors)
179
+ if color_enabled and RICH_AVAILABLE:
180
+ # Use Rich handler with colors
181
+ console_handler = RichHandler(
182
+ rich_tracebacks=True,
183
+ console=console,
184
+ show_time=True,
185
+ show_level=True,
186
+ show_path=False,
187
+ markup=True,
188
+ log_time_format="%Y-%m-%d %H:%M:%S",
189
+ omit_repeated_times=False,
190
+ )
191
+ console_handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
192
+ else:
193
+ # Use standard handler without colors
194
+ console_handler = logging.StreamHandler()
195
+ console_handler.setFormatter(
196
+ logging.Formatter(
197
+ fmt="%(asctime)s %(levelname)s:%(name)s:%(message)s",
198
+ datefmt="%Y-%m-%d %H:%M:%S %z",
199
+ )
200
+ )
201
+ logger.addHandler(console_handler)
202
+
203
+ # Check command line arguments for log file path (only if not in test environment)
204
+ args = None
205
+ try:
206
+ # Only parse arguments if we're not in a test environment
207
+ import os
208
+
209
+ if not os.environ.get("MMRELAY_TESTING"):
210
+ from mmrelay.cli import parse_arguments
211
+
212
+ args = parse_arguments()
213
+ except (SystemExit, ImportError):
214
+ # If argument parsing fails (e.g., in tests), continue without CLI arguments
215
+ pass
216
+
217
+ # Check if file logging is enabled (default to True for better user experience)
218
+ if (
219
+ config is not None
220
+ and config.get("logging", {}).get("log_to_file", True)
221
+ or (args and args.logfile)
222
+ ):
223
+ # Priority: 1. Command line argument, 2. Config file, 3. Default location (~/.mmrelay/logs)
224
+ if args and args.logfile:
225
+ log_file = args.logfile
226
+ else:
227
+ config_log_file = (
228
+ config.get("logging", {}).get("filename")
229
+ if config is not None
230
+ else None
231
+ )
232
+
233
+ if config_log_file:
234
+ # Use the log file specified in config
235
+ log_file = config_log_file
236
+ else:
237
+ # Default to standard log directory
238
+ log_file = os.path.join(get_log_dir(), "mmrelay.log")
239
+
240
+ # Create log directory if it doesn't exist
241
+ log_dir = os.path.dirname(log_file)
242
+ if log_dir: # Ensure non-empty directory paths exist
243
+ os.makedirs(log_dir, exist_ok=True)
244
+
245
+ # Store the log file path for later use
246
+ if name == APP_DISPLAY_NAME:
247
+ global log_file_path
248
+ log_file_path = log_file
249
+
250
+ # Create a file handler for logging
251
+ try:
252
+ # Set up size-based log rotation
253
+ max_bytes = DEFAULT_LOG_SIZE_MB * LOG_SIZE_BYTES_MULTIPLIER
254
+ backup_count = DEFAULT_LOG_BACKUP_COUNT
255
+
256
+ if config is not None and "logging" in config:
257
+ max_bytes = config["logging"].get("max_log_size", max_bytes)
258
+ backup_count = config["logging"].get("backup_count", backup_count)
259
+ file_handler = RotatingFileHandler(
260
+ log_file, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
261
+ )
262
+ except Exception as e:
263
+ print(f"Error creating log file at {log_file}: {e}")
264
+ return logger # Return logger without file handler
265
+
266
+ file_handler.setFormatter(
267
+ logging.Formatter(
268
+ fmt="%(asctime)s %(levelname)s:%(name)s:%(message)s",
269
+ datefmt="%Y-%m-%d %H:%M:%S %z",
270
+ )
271
+ )
272
+ logger.addHandler(file_handler)
273
+
274
+ return logger