Skip to content

Commit 28ff6e9

Browse files
dvilelafclaude
andcommitted
fix: make configure_logger resilient to PermissionError on mounted data dirs
- Adds stderr sink before attempting file logging so startup never crashes - Wraps DATA_DIR.mkdir + logger.add(file) in try/except (PermissionError, OSError) - Falls back to console-only logging with a warning showing exception type only (avoids exposing system paths in log messages) - Adds threading.Lock with double-checked locking for thread-safety - Adds 5 new tests covering happy path, PermissionError/OSError on mkdir, PermissionError on file add, and idempotency of the configured flag Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fbc649b commit 28ff6e9

4 files changed

Lines changed: 133 additions & 27 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "iwa"
3-
version = "0.8.1"
3+
version = "0.8.2"
44
description = "A secure, modular, and plugin-based framework for crypto agents and ops"
55
readme = "README.md"
66
requires-python = ">=3.12,<4.0"

src/iwa/core/utils.py

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
"""Utility functions"""
22

3+
import threading
4+
35
from loguru import logger
46
from safe_eth.eth import EthereumNetwork
57
from safe_eth.safe.addresses import MASTER_COPIES, PROXY_FACTORIES
68

9+
_logger_lock = threading.Lock()
10+
711

812
def singleton(cls):
913
"""Singleton decorator to ensure a class has only one instance."""
@@ -54,37 +58,40 @@ def configure_logger():
5458
if hasattr(configure_logger, "configured"):
5559
return logger
5660

57-
import logging
61+
with _logger_lock:
62+
if hasattr(configure_logger, "configured"): # double-checked locking
63+
return logger
5864

59-
from iwa.core.constants import DATA_DIR
65+
import logging
66+
import sys
6067

61-
# Silence noisy third-party loggers (these use stdlib logging, not loguru)
62-
logging.getLogger("apscheduler.scheduler").setLevel(logging.WARNING)
63-
logging.getLogger("apscheduler.executors.default").setLevel(logging.WARNING)
64-
logging.getLogger("httpx").setLevel(logging.WARNING)
68+
from iwa.core.constants import DATA_DIR
6569

66-
logger.remove()
70+
# Silence noisy third-party loggers (these use stdlib logging, not loguru)
71+
logging.getLogger("apscheduler.scheduler").setLevel(logging.WARNING)
72+
logging.getLogger("apscheduler.executors.default").setLevel(logging.WARNING)
73+
logging.getLogger("httpx").setLevel(logging.WARNING)
6774

68-
# Ensure data directory exists
69-
DATA_DIR.mkdir(parents=True, exist_ok=True)
75+
logger.remove()
7076

71-
import sys
77+
# Restore console logging (stderr) so logs are visible in docker/systemd/frontend streams
78+
logger.add(sys.stderr, level="INFO")
7279

73-
logger.add(
74-
DATA_DIR / "iwa.log",
75-
rotation="10 MB",
76-
level="INFO",
77-
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}",
78-
)
80+
# Attempt file logging; silently skip if the data dir isn't writable (e.g. read-only mounts)
81+
try:
82+
DATA_DIR.mkdir(parents=True, exist_ok=True)
83+
logger.add(
84+
DATA_DIR / "iwa.log",
85+
rotation="10 MB",
86+
level="INFO",
87+
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}",
88+
)
89+
except (PermissionError, OSError) as exc:
90+
# Log only the exception type to avoid exposing system paths in warnings
91+
logger.warning("iwa log file unavailable ({}), falling back to console-only logging", type(exc).__name__)
7992

80-
# Restore console logging (stderr) so logs are visible in docker/systemd/frontend streams
81-
logger.add(sys.stderr, level="INFO")
82-
# Also keep stderr for console if needed, but Textual captures it?
83-
# Textual usually captures stderr. Writing to file is safer for debugging.
84-
# Users previous logs show stdout format?
85-
86-
configure_logger.configured = True
87-
return logger
93+
configure_logger.configured = True
94+
return logger
8895

8996

9097
def get_version(package_name: str) -> str:

src/tests/test_utils.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,107 @@
1+
import sys
12
from unittest.mock import patch
23

34
import pytest
45

5-
from iwa.core.utils import get_safe_master_copy_address, singleton
6+
from iwa.core.utils import configure_logger, get_safe_master_copy_address, singleton
7+
8+
9+
@pytest.fixture
10+
def reset_configure_logger():
11+
"""Remove the configured flag so configure_logger can be called fresh in each test."""
12+
had_flag = hasattr(configure_logger, "configured")
13+
if had_flag:
14+
delattr(configure_logger, "configured")
15+
yield
16+
# Restore flag state so other tests aren't affected
17+
if hasattr(configure_logger, "configured"):
18+
delattr(configure_logger, "configured")
19+
if had_flag:
20+
configure_logger.configured = True
21+
22+
23+
class TestConfigureLogger:
24+
def test_happy_path(self, reset_configure_logger, tmp_path):
25+
"""File logging and stderr both succeed — stderr added first, then file."""
26+
with (
27+
patch("iwa.core.utils.logger") as mock_logger,
28+
patch("iwa.core.constants.DATA_DIR", tmp_path),
29+
):
30+
result = configure_logger()
31+
32+
mock_logger.remove.assert_called_once()
33+
assert mock_logger.add.call_count == 2 # stderr + file
34+
# Verify stderr is the first sink (order matters for fallback logic)
35+
first_call_args = mock_logger.add.call_args_list[0]
36+
assert first_call_args == ((sys.stderr,), {"level": "INFO"})
37+
assert configure_logger.configured is True
38+
assert result is mock_logger
39+
40+
def test_permission_error_on_mkdir(self, reset_configure_logger):
41+
"""PermissionError from mkdir → warning with type name, only stderr sink, no crash."""
42+
with (
43+
patch("iwa.core.utils.logger") as mock_logger,
44+
patch("iwa.core.constants.DATA_DIR") as mock_data_dir,
45+
):
46+
mock_data_dir.mkdir.side_effect = PermissionError("read-only file system")
47+
48+
configure_logger()
49+
50+
# Only stderr was added (mkdir failed before logger.add for file)
51+
mock_logger.add.assert_called_once_with(sys.stderr, level="INFO")
52+
mock_logger.warning.assert_called_once()
53+
warning_msg = mock_logger.warning.call_args[0][0]
54+
assert "unavailable" in warning_msg
55+
assert configure_logger.configured is True
56+
57+
def test_oserror_on_mkdir(self, reset_configure_logger):
58+
"""OSError (e.g. disk full) from mkdir → same fallback as PermissionError."""
59+
with (
60+
patch("iwa.core.utils.logger") as mock_logger,
61+
patch("iwa.core.constants.DATA_DIR") as mock_data_dir,
62+
):
63+
mock_data_dir.mkdir.side_effect = OSError("no space left on device")
64+
65+
configure_logger()
66+
67+
mock_logger.add.assert_called_once_with(sys.stderr, level="INFO")
68+
mock_logger.warning.assert_called_once()
69+
assert "unavailable" in mock_logger.warning.call_args[0][0]
70+
assert configure_logger.configured is True
71+
72+
def test_permission_error_on_file_add(self, reset_configure_logger):
73+
"""PermissionError from logger.add(file) → warning with type name, fallback to stderr."""
74+
with (
75+
patch("iwa.core.utils.logger") as mock_logger,
76+
patch("iwa.core.constants.DATA_DIR") as mock_data_dir,
77+
):
78+
mock_data_dir.mkdir.return_value = None
79+
# First add (stderr) succeeds; second add (file) raises PermissionError
80+
mock_logger.add.side_effect = [None, PermissionError("permission denied")]
81+
82+
configure_logger()
83+
84+
assert mock_logger.add.call_count == 2
85+
mock_logger.warning.assert_called_once()
86+
assert "unavailable" in mock_logger.warning.call_args[0][0]
87+
assert configure_logger.configured is True
88+
89+
def test_configured_flag_prevents_reinit(self, reset_configure_logger):
90+
"""Second call to configure_logger() returns immediately without re-initializing."""
91+
with (
92+
patch("iwa.core.utils.logger") as mock_logger,
93+
patch("iwa.core.constants.DATA_DIR") as mock_data_dir,
94+
):
95+
mock_data_dir.mkdir.return_value = None
96+
configure_logger()
97+
# Verify first call did initialize
98+
assert mock_logger.add.call_count >= 1
99+
mock_logger.reset_mock()
100+
101+
configure_logger()
102+
103+
mock_logger.remove.assert_not_called()
104+
mock_logger.add.assert_not_called()
6105

7106

8107
def test_get_safe_master_copy_address_found():

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)