Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 76 additions & 14 deletions src/ecli/utils/logging_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
- Optional separate error log file (error.log) for ERROR and CRITICAL events.
- Optional key event tracing (keytrace.log) enabled via the MOOPS2_KEYTRACE environment variable.
- Automatic creation of log directories, with fallback to the system temp directory on failure.
- Safe reconfiguration: clears existing handlers to avoid duplicate logs when called multiple times.
- Safe reconfiguration: removes and closes existing handlers to avoid duplicate logs
and stale file descriptors when called multiple times.
- Never raises exceptions; setup-time failures use explicit stderr fallback before curses takes ownership.

Usage:
Expand All @@ -49,6 +50,7 @@
import os
import sys
import tempfile
import threading
from types import TracebackType
from typing import Any, Optional

Expand All @@ -60,6 +62,64 @@
KEY_LOGGER = logging.getLogger("ecli.keyevents") # raw key-press trace


class SafeRotatingFileHandler(logging.handlers.RotatingFileHandler):
"""Rotating file handler that never lets rollover failures reach stderr."""

_rollover_lock = threading.RLock()

def doRollover(self) -> None:
"""Perform rollover with in-process serialization and recovery."""
with self._rollover_lock:
try:
super().doRollover()
except FileNotFoundError:
self._recover_base_stream()
except OSError:
self._recover_base_stream()
Comment on lines +70 to +78

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Collapse the duplicate except branches.

FileNotFoundError is a subclass of OSError, and both handlers invoke the same _recover_base_stream(). The separate branch is dead and can be merged for clarity.

♻️ Proposed simplification
         with self._rollover_lock:
             try:
                 super().doRollover()
-            except FileNotFoundError:
-                self._recover_base_stream()
             except OSError:
                 self._recover_base_stream()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def doRollover(self) -> None:
"""Perform rollover with in-process serialization and recovery."""
with self._rollover_lock:
try:
super().doRollover()
except FileNotFoundError:
self._recover_base_stream()
except OSError:
self._recover_base_stream()
def doRollover(self) -> None:
"""Perform rollover with in-process serialization and recovery."""
with self._rollover_lock:
try:
super().doRollover()
except OSError:
self._recover_base_stream()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ecli/utils/logging_config.py` around lines 70 - 78, The doRollover method
in the logging handler has duplicate exception handling because
FileNotFoundError is already covered by OSError, and both branches call
_recover_base_stream(). Simplify the rollover logic by keeping a single except
OSError branch in doRollover and routing all recovery through
_recover_base_stream(), using the existing _rollover_lock and superclass call
unchanged.


def handleError(self, record: logging.LogRecord) -> None:
"""Suppress logging-internal stderr writes during curses runtime."""
del record
return

Check warning on line 83 in src/ecli/utils/logging_config.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this redundant return.

See more on https://sonarcloud.io/project/issues?id=SSobol77_ecli&issues=AZ7_N57Km0zzAkiNzMEd&open=AZ7_N57Km0zzAkiNzMEd&pullRequest=116
Comment on lines +80 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the redundant trailing return.

The explicit return at the end of a None-returning method is superfluous (SonarCloud). The del record already documents the intentionally-unused parameter.

♻️ Proposed cleanup
     def handleError(self, record: logging.LogRecord) -> None:
         """Suppress logging-internal stderr writes during curses runtime."""
         del record
-        return
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def handleError(self, record: logging.LogRecord) -> None:
"""Suppress logging-internal stderr writes during curses runtime."""
del record
return
def handleError(self, record: logging.LogRecord) -> None:
"""Suppress logging-internal stderr writes during curses runtime."""
del record
🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[warning] 83-83: Remove this redundant return.

See more on https://sonarcloud.io/project/issues?id=SSobol77_ecli&issues=AZ7_N57Km0zzAkiNzMEd&open=AZ7_N57Km0zzAkiNzMEd&pullRequest=116

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ecli/utils/logging_config.py` around lines 80 - 83, The handleError
method in Logging-related code has a redundant trailing return after already
declaring a None return type and deleting the unused record parameter. Remove
the explicit return from handleError in the logging_config module so the method
ends after del record, keeping the suppression behavior unchanged.

Source: Linters/SAST tools


def _recover_base_stream(self) -> None:
"""Best-effort reopen of the base log after a failed rollover."""
self._close_stream_quietly()
if self.delay:
return
try:
log_dir = os.path.dirname(self.baseFilename)
if log_dir:
os.makedirs(log_dir, exist_ok=True)
self.stream = self._open()
except OSError:
self.stream = None

def _close_stream_quietly(self) -> None:
stream = self.stream
self.stream = None
if stream is None:
return
try:
stream.close()
except OSError:
return


def _remove_and_close_handlers(target_logger: logging.Logger) -> None:
"""Detach, flush, and close all handlers currently on ``target_logger``."""
for handler in target_logger.handlers[:]:
target_logger.removeHandler(handler)
try:
handler.flush()
except (OSError, ValueError):
pass
try:
handler.close()
except (OSError, ValueError):
pass


def log_exception_to_file_handlers(
message: str,
exc: BaseException,
Expand Down Expand Up @@ -135,9 +195,9 @@
when the environment variable ``MOOPS2_KEYTRACE`` is set to
``1/true/yes``; attached to the ``ecli.keyevents`` logger.

Existing handlers on the root logger are cleared to avoid duplicate
records when the function is invoked multiple times (e.g. in unit
tests).
Existing handlers on the root and key-event loggers are removed and closed
to avoid duplicate records and stale file descriptors when the function is
invoked multiple times (e.g. in unit tests).

Args:
config (dict | None): Optional application configuration blob.
Expand All @@ -156,7 +216,7 @@
Side Effects:
- Creates directories for log files if they don’t exist; falls
back to the system temp directory on failure.
- Replaces all handlers on the root logger.
- Replaces all handlers on the root logger after closing old handlers.
- Configures the namespace logger ``ecli.keyevents`` to not
propagate and attaches/clears its handlers independently.

Expand All @@ -176,6 +236,8 @@
... }
>>> setup_logging(config)
"""
logging.raiseExceptions = False

if config is None:
config = {}
# Main Application Logger (e.g., for editor.log). The log directory follows
Expand Down Expand Up @@ -204,9 +266,14 @@
file_formatter = logging.Formatter(
"%(asctime)s - %(levelname)-8s - %(name)-15s - %(message)s (%(filename)s:%(lineno)d)"
)
root_logger = logging.getLogger()
key_event_logger = logging.getLogger("ecli.keyevents")
_remove_and_close_handlers(root_logger)
_remove_and_close_handlers(key_event_logger)

file_handler = None
try:
file_handler = logging.handlers.RotatingFileHandler(
file_handler = SafeRotatingFileHandler(
log_filename, maxBytes=2 * 1024 * 1024, backupCount=5, encoding="utf-8"
)
file_handler.setFormatter(file_formatter)
Expand Down Expand Up @@ -242,7 +309,7 @@
if error_log_dir and not os.path.exists(error_log_dir):
os.makedirs(error_log_dir, exist_ok=True)

error_file_handler = logging.handlers.RotatingFileHandler(
error_file_handler = SafeRotatingFileHandler(
error_log_filename,
maxBytes=1 * 1024 * 1024,
backupCount=3,
Expand All @@ -257,9 +324,6 @@
)

# Configure the root logger
root_logger = logging.getLogger()
root_logger.handlers = [] # Clear existing root handlers to avoid duplicates

if file_handler:
root_logger.addHandler(file_handler)
if console_handler:
Expand All @@ -280,11 +344,9 @@
logging.getLogger("asyncio").setLevel(logging.INFO)

# Key Event Logger
key_event_logger = logging.getLogger("ecli.keyevents")
key_event_logger.propagate = False
key_event_logger.setLevel(logging.DEBUG)
# Clear any handlers that might have been added if setup_logging is called multiple times
key_event_logger.handlers = []
key_event_logger.disabled = False

if os.environ.get("MOOPS2_KEYTRACE", "").lower() in {"1", "true", "yes"}:
try:
Expand All @@ -293,7 +355,7 @@
if key_trace_log_dir and not os.path.exists(key_trace_log_dir):
os.makedirs(key_trace_log_dir, exist_ok=True)

key_trace_handler = logging.handlers.RotatingFileHandler(
key_trace_handler = SafeRotatingFileHandler(
key_trace_filename,
maxBytes=1 * 1024 * 1024,
backupCount=3,
Expand Down
Loading
Loading