# Copyright (C) 2024–present Loren Eteval & contributors # # This file is part of Furious. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Collect, classify, and publish logs independently from their presentation.""" from __future__ import annotations from Furious.Models.Logging import LogCategory, LogEntry from PySide6 import QtCore from collections import deque from datetime import datetime from typing import Optional import logging import threading __all__ = [ 'ALL_LOGS_FILTER', 'APPLICATION_LOG_CATEGORY', 'CORE_LOG_CATEGORY', 'TUN2SOCKS_LOG_CATEGORY', 'ApplicationLogHandler', 'LogManager', 'coreLogCallback', 'formatLogEntry', ] ALL_LOGS_FILTER = 'all' APPLICATION_LOG_CATEGORY = 'application' CORE_LOG_CATEGORY = 'core' TUN2SOCKS_LOG_CATEGORY = 'component.tun2socks' def formatLogEntry(entry: LogEntry) -> str: """Return the producer-formatted text stored by one structured entry.""" return entry.message class LogManager(QtCore.QObject): """Own the application-wide categorized log stream.""" DefaultMaximumEntries = 10_000 DefaultAutoClearMaximumEntries = 5_000 categoryRegistered = QtCore.Signal(object) entryAdded = QtCore.Signal(object) entriesCleared = QtCore.Signal(object) def __init__( self, parent=None, *, maximumEntries=DefaultMaximumEntries, autoClearMaximumEntries=DefaultAutoClearMaximumEntries, autoClearEnabled=True, ): """Initialize the category registry and thread-safe entry collection.""" super().__init__(parent) if ( isinstance(maximumEntries, bool) or not isinstance(maximumEntries, int) or maximumEntries <= 0 ): raise ValueError('maximumEntries must be a positive integer') if ( isinstance(autoClearMaximumEntries, bool) or not isinstance(autoClearMaximumEntries, int) or autoClearMaximumEntries <= 0 ): raise ValueError('autoClearMaximumEntries must be a positive integer') self._lock = threading.RLock() self._categories: dict[str, LogCategory] = {} self._maximumEntries = maximumEntries self._autoClearMaximumEntries = autoClearMaximumEntries self._autoClearEnabled = bool(autoClearEnabled) self._entries: deque[LogEntry] = deque(maxlen=maximumEntries) self._categoryEntryCounts: dict[str, int] = {} self._sequence = 0 self.registerCategory( LogCategory( APPLICATION_LOG_CATEGORY, 'Application', translatable=True, ) ) self.registerCategory( LogCategory( CORE_LOG_CATEGORY, 'Core', translatable=True, runtime=True, ) ) self.registerCategory( LogCategory( TUN2SOCKS_LOG_CATEGORY, 'Tun2socks', runtime=True, ) ) @property def maximumEntries(self) -> int: """Return the maximum number of structured entries retained in memory.""" return self._maximumEntries @property def autoClearMaximumEntries(self) -> int: """Return the automatic-clear threshold for replaceable runtime logs.""" return self._autoClearMaximumEntries @property def autoClearEnabled(self) -> bool: """Return whether automatic clearing triggered by Core is active.""" with self._lock: return self._autoClearEnabled def _nonApplicationCategoryIdsLocked(self) -> set[str]: """Return every registered category except the persistent Application log.""" return set(self._categories).difference({APPLICATION_LOG_CATEGORY}) def _removeCategoriesLocked(self, categoryIds: set[str]): """Remove selected categories while the caller owns ``_lock``.""" self._entries = deque( (entry for entry in self._entries if entry.categoryId not in categoryIds), maxlen=self._maximumEntries, ) for categoryId in categoryIds: self._categoryEntryCounts[categoryId] = 0 def setAutoClearEnabled(self, enabled: bool): """Apply Core-triggered clearing without involving presentation state.""" clearedCategoryIds = set() with self._lock: self._autoClearEnabled = bool(enabled) if ( self._autoClearEnabled and self._categoryEntryCounts.get(CORE_LOG_CATEGORY, 0) >= self._autoClearMaximumEntries ): clearedCategoryIds = self._nonApplicationCategoryIdsLocked() self._removeCategoriesLocked(clearedCategoryIds) if clearedCategoryIds: self.entriesCleared.emit(frozenset(clearedCategoryIds)) def entryCount(self, categoryId: Optional[str] = None) -> int: """Return the retained total or one category count in constant time.""" with self._lock: if categoryId in (None, ALL_LOGS_FILTER): return len(self._entries) if categoryId not in self._categories: raise KeyError(f'unknown log category {categoryId!r}') return self._categoryEntryCounts.get(categoryId, 0) def registerCategory(self, category: LogCategory) -> LogCategory: """Register a filterable category and publish it exactly once.""" if not isinstance(category, LogCategory): raise TypeError('category must be a LogCategory') with self._lock: existing = self._categories.get(category.id) if existing is not None: if existing != category: raise ValueError( f'log category {category.id!r} is already registered ' f'with different metadata' ) return existing self._categories[category.id] = category self._categoryEntryCounts[category.id] = 0 self.categoryRegistered.emit(category) return category def registerComponent( self, identifier: str, displayName: str, *, runtime: bool = True, translatable: bool = False, ) -> LogCategory: """Register a component using its stable identifier and display label.""" return self.registerCategory( LogCategory( identifier, displayName, translatable=translatable, runtime=runtime, ) ) def category(self, categoryId: str) -> Optional[LogCategory]: """Return a registered category by stable identifier.""" with self._lock: return self._categories.get(categoryId) def categories(self) -> tuple[LogCategory, ...]: """Return registered categories in registration order.""" with self._lock: return tuple(self._categories.values()) def append( self, message, categoryId: str = APPLICATION_LOG_CATEGORY, *, timestamp: Optional[datetime] = None, source: str = '', severity: str = '', ) -> LogEntry: """Append a structured entry and notify interested presenters.""" clearedCategoryIds = set() with self._lock: category = self._categories.get(categoryId) if category is None: raise KeyError(f'unknown log category {categoryId!r}') if timestamp is None: timestamp = datetime.now() elif not isinstance(timestamp, datetime): raise TypeError('log timestamp must be a datetime') self._sequence += 1 entry = LogEntry( message=str(message).rstrip('\r\n'), timestamp=timestamp, categoryId=category.id, categoryLabel=category.displayName, categoryTranslatable=category.translatable, source=str(source) if source else '', severity=str(severity) if severity else '', sequence=self._sequence, ) if ( category.id == CORE_LOG_CATEGORY and self._autoClearEnabled and self._categoryEntryCounts[CORE_LOG_CATEGORY] >= self._autoClearMaximumEntries ): # When the next Core line would exceed its threshold, retain # only Application diagnostics. All other categories belong # to the replaceable runtime stream for this policy. clearedCategoryIds = self._nonApplicationCategoryIdsLocked() self._removeCategoriesLocked(clearedCategoryIds) if len(self._entries) == self._maximumEntries: evicted = self._entries[0] self._categoryEntryCounts[evicted.categoryId] -= 1 self._entries.append(entry) self._categoryEntryCounts[entry.categoryId] += 1 if clearedCategoryIds: self.entriesCleared.emit(frozenset(clearedCategoryIds)) self.entryAdded.emit(entry) return entry def callback( self, categoryId: str, *, source: str = '', severity: str = '', ): """Return a safe line callback for a process or another log producer.""" def appendLine(line): """Append one externally produced line without affecting its producer.""" try: self.append( line, categoryId, source=source, severity=severity, ) except Exception: # Any non-exit exceptions pass return appendLine def entries(self, categoryId: Optional[str] = None) -> tuple[LogEntry, ...]: """Return an immutable snapshot, optionally filtered by category.""" return self.snapshot(categoryId)[1] def snapshot( self, categoryId: Optional[str] = None, ) -> tuple[int, tuple[LogEntry, ...]]: """Return the current sequence and its immutable filtered entries.""" with self._lock: if categoryId in (None, ALL_LOGS_FILTER): entries = tuple(self._entries) else: entries = tuple( entry for entry in self._entries if entry.categoryId == categoryId ) return self._sequence, entries def clear( self, categoryId: Optional[str] = None, *, runtimeOnly: bool = False, ): """Clear all, one category, or every transient runtime category.""" if categoryId is not None and runtimeOnly: raise ValueError('categoryId and runtimeOnly cannot be combined') with self._lock: if runtimeOnly: clearedCategoryIds = { category.id for category in self._categories.values() if category.runtime } elif categoryId is None or categoryId == ALL_LOGS_FILTER: clearedCategoryIds = None else: if categoryId not in self._categories: raise KeyError(f'unknown log category {categoryId!r}') clearedCategoryIds = {categoryId} if clearedCategoryIds is None: changed = bool(self._entries) self._entries.clear() for registeredCategoryId in self._categoryEntryCounts: self._categoryEntryCounts[registeredCategoryId] = 0 else: oldLength = len(self._entries) self._removeCategoriesLocked(clearedCategoryIds) changed = len(self._entries) != oldLength if changed: self.entriesCleared.emit( None if clearedCategoryIds is None else frozenset(clearedCategoryIds) ) def plainText( self, categoryId: Optional[str] = None, ) -> str: """Return a plain-text snapshot for exporting or crash diagnostics.""" return '\n'.join(formatLogEntry(entry) for entry in self.entries(categoryId)) class ApplicationLogHandler(logging.Handler): """Convert standard-library log records into structured application entries.""" def __init__(self, manager: LogManager): """Bind the handler to the application-wide log manager.""" super().__init__() if not isinstance(manager, LogManager): raise TypeError('manager must be a LogManager') self.manager = manager def emit(self, record: logging.LogRecord): """Publish one logging record without coupling it to a widget.""" try: self.manager.append( self.format(record), APPLICATION_LOG_CATEGORY, timestamp=datetime.fromtimestamp(record.created), source=record.name, severity=record.levelname, ) except Exception: # Any non-exit exceptions self.handleError(record) def coreLogCallback(manager: LogManager): """Create a callback for output from the currently selected proxy core.""" return manager.callback(CORE_LOG_CATEGORY)