Repository: sdv-dev/SDV

Issue:
Allow SDV to be used on a readonly filesystem
### Problem Description
As mentioned in SDV issues #2435  and #2508, it is currently not possible to install and use SDV in a readonly filesystem due to SDV's integration with `logging`. SDV is designed to create a folder for logging; logs are meant to contain messages that may be informative for debugging purposes, but are not really critical enough to be warnings or errors.

In a readonly filesystem, even just doing `import sdv` causes a crash because it tries to run the [`get_sdv_logger_config()`](https://github.com/sdv-dev/SDV/blob/main/sdv/logging/utils.py#L14) function, which does not account for read/write permissions.

### Expected behavior
Allow SDV to be installed and used on a readonly filesystem.

1. If I am on a readonly filesystem, then SDV should be able to skip over any instructions for writing a folder, writing to a file, etc. without me having to modify anything. (Possible solutions: We could patch, try/catch, or update the `get_sdv_logger_cofig` function, as well as any relevant areas where the logs are being written.)
2. If writing is allowed in my filesystem, then there should be no changes to SDV. That is: SDV should continue to log by default if it can.

### Additional context
The ability to completely opt-out of logging (even if I'm on a read/write filesystem) is not in-scope for this issue. 


Discussion:

Patch:
diff --git a/sdv/logging/logger.py b/sdv/logging/logger.py
index 0d073241c..415621b06 100644
--- a/sdv/logging/logger.py
+++ b/sdv/logging/logger.py
@@ -3,7 +3,7 @@
 import csv
 import logging
 import os
-from functools import lru_cache
+from functools import lru_cache, wraps
 from io import StringIO
 
 from sdv.logging.utils import get_sdv_logger_config
@@ -42,7 +42,42 @@ def format(self, record):  # noqa: A003
         return data.strip()
 
 
+def safely_return_logger(func):
+    """Decorator to safely return a logger from a function that may raise a `PermissionError`.
+
+    If the decorated function raises a `PermissionError` (commonly due to file-based
+    logging to an unwritable path), this decorator catches the error and returns a
+    fallback logger configured with a NullHandler instead. This prevents the application
+    from crashing due to logging setup failures.
+
+    The fallback logger is named after the current module (__name__) and will silently
+    discard all logs.
+
+    Args:
+        func (Callable):
+            A function that returns a logger and may raise PermissionError.
+
+    Returns:
+        Callable:
+            A wrapped function that returns either the intended logger or a
+            fallback logger with a NullHandler.
+    """
+
+    @wraps(func)
+    def wrapper(*args, **kwargs):
+        try:
+            return func(*args, **kwargs)
+        except PermissionError:
+            fallback_logger = logging.getLogger(__name__)
+            fallback_logger.addHandler(logging.NullHandler())
+            fallback_logger.warning('Falling back to NullHandler logger due to PermissionError.')
+            return fallback_logger
+
+    return wrapper
+
+
 @lru_cache()
+@safely_return_logger
 def get_sdv_logger(logger_name):
     """Get a logger instance with the specified name and configuration.
 
diff --git a/sdv/logging/utils.py b/sdv/logging/utils.py
index cbe806152..83b043364 100644
--- a/sdv/logging/utils.py
+++ b/sdv/logging/utils.py
@@ -2,6 +2,7 @@
 
 import contextlib
 import logging
+import os
 import shutil
 import tempfile
 from pathlib import Path
@@ -14,18 +15,32 @@
 def get_sdv_logger_config():
     """Return a dictionary with the logging configuration."""
     store_path = Path(platformdirs.user_data_dir('sdv', 'sdv-dev'))
-    store_path.mkdir(parents=True, exist_ok=True)
     config_path = Path(__file__).parent / 'sdv_logger_config.yml'
+    logger_conf = {}
+
+    try:
+        store_path.mkdir(parents=True, exist_ok=True)
+
+    except PermissionError:
+        # No access to user's data.
+        if os.access(config_path, os.W_OK):
+            store_path = config_path
+
+        else:
+            # No access to python env
+            return logger_conf
 
     if (store_path / 'sdv_logger_config.yml').exists():
         config_path = store_path / 'sdv_logger_config.yml'
-    else:
+
+    elif os.access(store_path, os.W_OK):
         tmp_path = tempfile.mktemp(dir=store_path, suffix='.yml')
         shutil.copyfile(config_path, tmp_path)
         shutil.move(tmp_path, store_path / 'sdv_logger_config.yml')
 
-    with open(config_path, 'r') as f:
-        logger_conf = yaml.safe_load(f)
+    if os.access(config_path, os.R_OK):
+        with open(config_path, 'r') as f:
+            logger_conf = yaml.safe_load(f)
 
     for logger in logger_conf.get('loggers', {}).values():
         handler = logger.get('handlers', {})
@@ -46,8 +61,12 @@ def disable_single_table_logger():
     """
     # Logging without ``SingleTableSynthesizer``
     single_table_logger = logging.getLogger('SingleTableSynthesizer')
-    handlers = single_table_logger.handlers
-    single_table_logger.handlers = []
+    if single_table_logger:
+        handlers = single_table_logger.handlers
+        single_table_logger.handlers = []
+    else:
+        handlers = []
+
     try:
         yield
     finally:
diff --git a/sdv/single_table/base.py b/sdv/single_table/base.py
index c7f2e5e2c..7e9f76c15 100644
--- a/sdv/single_table/base.py
+++ b/sdv/single_table/base.py
@@ -41,6 +41,7 @@
 from sdv.single_table.utils import check_num_rows, handle_sampling_error, validate_file_path
 
 LOGGER = logging.getLogger(__name__)
+
 SYNTHESIZER_LOGGER = get_sdv_logger('SingleTableSynthesizer')
 
 COND_IDX = str(uuid.uuid4())
diff --git a/sdv/single_table/utils.py b/sdv/single_table/utils.py
index 1f6459735..e48e93707 100644
--- a/sdv/single_table/utils.py
+++ b/sdv/single_table/utils.py
@@ -168,9 +168,16 @@ def validate_file_path(output_file_path):
         # Do not save a file if the user specified not to save a file.
         return None
 
-    # Create the file.
-    with open(output_path, 'w+'):
-        pass
+    try:
+        # Attempt to create the file to verify it's writable
+        with open(output_path, 'w+'):
+            pass
+
+    except PermissionError:
+        warnings.warn(
+            f"Permission denied: cannot write to '{output_path}'. Skipping file creation."
+        )
+        return None
 
     return output_path
 


Test patch:
diff --git a/tests/unit/logging/test_logger.py b/tests/unit/logging/test_logger.py
index 8fa55efd9..84dc3ec37 100644
--- a/tests/unit/logging/test_logger.py
+++ b/tests/unit/logging/test_logger.py
@@ -49,6 +49,7 @@ def test_format(self):
 @patch('sdv.logging.logger.logging.getLogger')
 @patch('sdv.logging.logger.get_sdv_logger_config')
 def test_get_sdv_logger(mock_get_sdv_logger_config, mock_getlogger, mock_streamhandler):
+    """Test basic logger setup with StreamHandler from config."""
     # Setup
     mock_logger_conf = {
         'log_registry': 'local',
@@ -76,6 +77,7 @@ def test_get_sdv_logger(mock_get_sdv_logger_config, mock_getlogger, mock_streamh
 def test_get_sdv_logger_csv(
     mock_get_sdv_logger_config, mock_getlogger, mock_filehandler, mock_csvformatter
 ):
+    """Test logger setup with FileHandler and CSVFormatter from config."""
     # Setup
     mock_logger_conf = {
         'log_registry': 'local',
@@ -103,3 +105,16 @@ def test_get_sdv_logger_csv(
     mock_filehandler.assert_called_once_with('logfile.csv')
     mock_filehandler_instance.setLevel.assert_called_once_with(logging.DEBUG)
     mock_filehandler_instance.setFormatter.assert_called_once_with(mock_csvformatter.return_value)
+
+
+@patch('sdv.logging.logger.get_sdv_logger_config', side_effect=PermissionError)
+def test_get_sdv_logger_permission_error(mock_get_sdv_logger_config):
+    """Test fallback to NullHandler when logger config raises PermissionError."""
+    # Setup
+    get_sdv_logger.cache_clear()
+
+    # Run
+    logger = get_sdv_logger('test_logger_csv')
+
+    # Assert
+    assert isinstance(logger.handlers[0], logging.NullHandler)
diff --git a/tests/unit/logging/test_utils.py b/tests/unit/logging/test_utils.py
index c053738c8..a28393ffd 100644
--- a/tests/unit/logging/test_utils.py
+++ b/tests/unit/logging/test_utils.py
@@ -42,6 +42,53 @@ def test_get_sdv_logger_config():
     }
 
 
+@patch('sdv.logging.utils.platformdirs.user_data_dir')
+@patch('sdv.logging.utils.Path.mkdir', side_effect=PermissionError)
+@patch('sdv.logging.utils.os.access', return_value=False)
+def test_get_logger_config_no_permissions(mock_os_access, mock_mkdir, mock_data_dir):
+    """Test get_sdv_logger_config when there is no write or read permission.
+
+    Return empty config when both user and default paths are inaccessible.
+    """
+    # Setup
+    mock_data_dir.return_value = '/no_permission/user/config'
+
+    # Run
+    config = get_sdv_logger_config()
+
+    # Assert
+    assert config == {}
+
+
+@patch('sdv.logging.utils.platformdirs.user_data_dir')
+@patch('sdv.logging.utils.Path.mkdir', side_effect=PermissionError)
+@patch('sdv.logging.utils.os.access')
+@patch('sdv.logging.utils.Path.exists', return_value=True)
+@patch('sdv.logging.utils.open', create=True)
+@patch('sdv.logging.utils.yaml.safe_load', return_value={'loggers': {}})
+def test_get_sdv_logger_config_fallback_to_config_path(
+    mock_yaml_load,
+    mock_open,
+    mock_exists,
+    mock_os_access,
+    mock_mkdir,
+    mock_user_data_dir,
+):
+    """Should fall back to config path if store path is not writable but config path is."""
+    # Setup
+    mock_user_data_dir.return_value = '/mock/user/data'
+    mock_os_access.side_effect = [True, True]
+
+    # Run
+    config = get_sdv_logger_config()
+
+    # Assert
+    assert isinstance(config, dict)
+    mock_mkdir.assert_called_once()
+    assert mock_os_access.call_count >= 2
+    mock_yaml_load.assert_called_once()
+
+
 @patch('sdv.logging.utils.logging.getLogger')
 def test_disable_single_table_logger(mock_getlogger):
     # Setup
diff --git a/tests/unit/single_table/test_utils.py b/tests/unit/single_table/test_utils.py
index 1d98e10c6..a58569941 100644
--- a/tests/unit/single_table/test_utils.py
+++ b/tests/unit/single_table/test_utils.py
@@ -1,3 +1,4 @@
+import warnings
 from unittest.mock import Mock, patch
 
 import numpy as np
@@ -337,6 +338,23 @@ def test_validate_file_path(mock_open):
     mock_open.assert_called_once_with(result, 'w+')
 
 
+@patch('builtins.open', side_effect=PermissionError('No permission'))
+def test_validate_file_path_permission_error(mock_open):
+    """Test validate_file_path emits a warning and returns None on PermissionError."""
+    # Setup
+    output_path = 'sample_output.csv'
+
+    # Run
+    with warnings.catch_warnings(record=True) as warned:
+        warnings.simplefilter('always')
+        result = validate_file_path(output_path)
+
+    # Assert
+    assert result is None
+    assert mock_open.called
+    assert any('Permission denied' in str(warning.message) for warning in warned)
+
+
 def test_warn_missing_numerical_distributions():
     """Test the warn_missing_numerical_distributions function."""
     # Setup