diff --git a/CHANGELOG.md b/CHANGELOG.md index 87376798..53fd5eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [5.108.6] - 2026-07-23 + +### Added + +- `Enhancement` Enhanced the error checking and logging on configuration file loading and increased the test coverage for configuration files in the unit tests. + +### Changed + +- `None` Nothing Changed + +### Fixed + +- `Unit tests` Fixed the unit tests related to configuration file loading + +### Removed + +- `None` Nothing removed + ## [5.108.5] - 2026-07-20 ### Added diff --git a/saspy/sasbase.py b/saspy/sasbase.py index 97f38bab..f975d017 100644 --- a/saspy/sasbase.py +++ b/saspy/sasbase.py @@ -111,14 +111,25 @@ def dbHTML(x): return(x) def list_configs() -> list: + """ + Informational method which shows you what sascfg_personal.py files are found in the + search order used by SASPy. + + :return: list of fully qualified paths to sascfg_personal.py files found + """ + cfg = [] sp = [] sp[:] = sys.path sp[0] = os.path.abspath(sp[0]) + + # Add the user folder after the local folder, but before the rest of the sys.path sp.insert(1, os.path.expanduser('~/.config/saspy')) + # Add the saspy package/library folder to the front of the path (global sascfg_personal.py file) sp.insert(0, __file__.rsplit(os.sep+'sasbase.py')[0]) for dir in sp: + logger.debug('Checking folder for config file: %s', dir) f1 = dir+os.sep+'sascfg_personal.py' if os.path.isfile(f1): cfg.append(f1) @@ -350,8 +361,8 @@ def _find_config(self, cfg_override: str=None): # functions, but we must support Python versions <= 3.4 (all EOL). cfg_expand = os.path.expanduser(cfg_override) - # Check file exists before proceeding - if not os.path.exists(cfg_expand): + # Check file exists, it is a file, and it is a python file. + if not os.path.exists(cfg_expand) or not os.path.isfile(cfg_expand) or not cfg_expand.endswith('.py'): raise SASConfigNotFoundError(cfg_expand) self.origin = cfg_expand @@ -360,7 +371,8 @@ def _find_config(self, cfg_override: str=None): tempdir = tempfile.TemporaryDirectory() tempname = "sascfg"+'%03d' % _cfgfile_cnt - shutil.copyfile(cfg_expand, os.path.join(tempdir.name, tempname+'.py')) + f = os.path.join(tempdir.name, tempname+'.py') + shutil.copyfile(cfg_expand, f) sys.path.append(tempdir.name) #import sascfgfile as SAScfg diff --git a/saspy/tests/test_sasconfig.py b/saspy/tests/test_sasconfig.py index 2e5fa5dd..67f21ef9 100644 --- a/saspy/tests/test_sasconfig.py +++ b/saspy/tests/test_sasconfig.py @@ -33,27 +33,6 @@ def _patch_import_module_none(name, package=None): else: return _real_import_module(name, package=package) -def _patch_import_nolocal(name, globals=None, locals=None, fromlist=(), level=0): - """ - Patch the __import__ function to always raise an exception for any - local `sascfg_personal` imports - """ - if name in ('sascfg_personal'): - raise ImportError - else: - return _real_import(name, globals=globals, locals=locals, fromlist=fromlist, level=level) - -def _patch_import_module_nolocal(name, package=None): - """ - Patch the importlib.import_module function to always raise an exception - for any local `sascfg_personal` imports - """ - if name in ('sascfg_personal'): - raise ImportError - else: - return _real_import_module(name, package=package) - - class TestSASConfig(unittest.TestCase): @classmethod def setUpClass(cls): @@ -67,6 +46,40 @@ def setUpClass(cls): cls.cfg_global_personal_path = os.path.join(install, 'sascfg_personal.py') cls.cfg_home_path = os.path.join(home, 'sascfg_personal.py') + def _backup_paths(self, paths:dict[str,bool]): + """ + Backup the paths in the dictionary, where each key is the original path and the value is a boolean indicating whether or not it should be 'restored' + """ + for path, _ in paths.items(): + if os.path.isfile(path): + # If the file exists, rename it to a backup file + backup_path = path + '.bak' + os.rename(path, backup_path) + print(f"Backed up {path} to {backup_path}") + paths[path] = True + else: + # If the file does not exist mark it as not needing to be restored + paths[path] = False + + def _restore_paths(self, paths:dict[str,bool]): + """ + Restore the paths in the dictionary, where each key is the original path and the value is a boolean indicating whether or not it should be 'restored' + """ + for path, should_restore in paths.items(): + if should_restore: + # If the file was backed up, restore it + backup_path = path + '.bak' + os.rename(backup_path, path) + print(f"Restored {backup_path} to {path}") + + def _reset_config_modules(self): + """Remove cached config modules to force fresh import on next access""" + for mod in ['sascfg', 'sascfg_personal']: + sys.modules.pop(mod, None) + # Reload the saspy module to ensure that any cached config modules are cleared + importlib.reload(saspy) + + @mock.patch('builtins.__import__', _patch_import_none) @mock.patch('importlib.import_module', _patch_import_module_none) def test_config_find_config_global_sascfg(self): @@ -82,8 +95,6 @@ def test_config_find_config_global_sascfg(self): self.assertEqual(cfg_src, self.cfg_global_standard_path) - @mock.patch('builtins.__import__', _patch_import_nolocal) - @mock.patch('importlib.import_module', _patch_import_module_nolocal) def test_config_find_config_global_sascfg_personal(self): """ Test that the global `sascfg_personal.py` file is read if no other configuration @@ -93,8 +104,22 @@ def test_config_find_config_global_sascfg_personal(self): cfg_manager = saspy.SASconfig() cfg_module = cfg_manager._find_config() + # Set up a dummy `sascfg_personal.py` file in the current working directory to test that it is not used + temp_cfg = os.path.join(os.getcwd(), 'sascfg_personal.py') + # Also check and see if the home directory has a `sascfg_personal.py` file, and if not create a temporary one for the test + if not os.path.isfile(self.cfg_home_path): + shutil.copy(self.cfg_global_standard_path, self.cfg_home_path) + # Ensure the copied file is removed after the test + self.addCleanup(lambda: os.remove(self.cfg_home_path)) + + # Make the dummy file a copy of the global `sascfg_personal.py` file to ensure it is valid + shutil.copy(self.cfg_global_standard_path, temp_cfg) + # Ensure the copied file is removed after the test + self.addCleanup(lambda: os.remove(temp_cfg)) + cfg_src = inspect.getfile(cfg_module) + # Ensure that the global `sascfg_personal.py` file is used, not the local ones in the current working directory or home directory self.assertEqual(cfg_src, self.cfg_global_personal_path) def test_config_find_config_parameter_exists(self): @@ -109,7 +134,7 @@ def test_config_find_config_parameter_exists(self): tmpdir = tempfile.TemporaryDirectory() tmpcfg = os.path.join(tmpdir.name, 'saspy_test_config.py') - shutil.copy(inspect.getfile(saspy.sascfg), tmpcfg) + shutil.copy(self.cfg_global_standard_path, tmpcfg) cfg_manager = saspy.SASconfig() cfg_module = cfg_manager._find_config(tmpcfg) @@ -128,3 +153,160 @@ def test_config_find_config_parameter_noexists(self): with self.assertRaises(saspy.SASConfigNotFoundError): cfg_manager = saspy.SASconfig() cfg_module = cfg_manager._find_config('/not/a/valid/config.py') + + def test_config_find_config_parameter_notfile(self): + """ + Test that a config file path passed to `_find_config` that is not a file + raises a SASConfigFileNotFoundError. + """ + with self.assertRaises(saspy.SASConfigNotFoundError): + cfg_manager = saspy.SASconfig() + cfg_module = cfg_manager._find_config(os.path.dirname(self.cfg_global_standard_path)) + + def test_config_find_config_parameter_notpy(self): + """ + Test that a config file path passed to `_find_config` that is not a .py file + raises a SASConfigFileNotFoundError. + """ + with self.assertRaises(saspy.SASConfigNotFoundError): + cfg_manager = saspy.SASconfig() + cfg_module = cfg_manager._find_config(os.path.join(os.path.dirname(self.cfg_global_standard_path), 'not_a_python_file.txt')) + + def test_config_find_config_parameter_notpy_but_exists(self): + """ + Test that a config file path passed to `_find_config` that is not a .py file + but does exist raises a SASConfigFileNotFoundError. + """ + tmpdir = tempfile.TemporaryDirectory() + tmpcfg = os.path.join(tmpdir.name, 'not_a_python_file.txt') + + with open(tmpcfg, 'w') as f: + f.write('This is not a python file.') + + with self.assertRaises(saspy.SASConfigNotFoundError): + cfg_manager = saspy.SASconfig() + cfg_module = cfg_manager._find_config(tmpcfg) + + tmpdir.cleanup() + + def test_config_find_config_order_of_inclusion(self): + """ + Test that the order of inclusion for config files is as follows: + 1. Config file passed as a parameter to `_find_config` + 2. Global `sascfg_personal.py` file in the saspy library path + 3. 'Local scope' sascfg_personal.py file + 4. Home directory `sascfg_personal.py` file + 5. Global `sascfg.py` file + """ + local_scope_path = os.path.join(sys.path[0], 'sascfg_personal.py') + PATHS:dict[str,bool] = { + self.cfg_global_standard_path: False, + self.cfg_global_personal_path: False, + self.cfg_home_path: False, + local_scope_path: False, + } + + # To test the order of inclusion, we will have to work 'backwards' through the list of paths, + # and ensure that the correct one is found at each step. + + # To simulate this, any existing config files will have to be temporarily moved/hidden. + # In the cases where a file already exists in one of the locations, + # we will rename it to a backup file and then restore it at the end of the test. + + # If a file did not exist at one of the locations, we will create a temporary file to + # simulate the presence of a config file at that location and then remove it at the end of the test. + + # This will be tracked in a list of tuples, where each tuple contains the original path and whether or not it should be 'restored' + + dummy_config_content = '# This is a temporary config file to simulate the presence of a config file at a specific location.\n' \ + 'SAS_config_names = ["dummy"]\n' \ + 'SAS_config_options = {"lock_down": False, "verbose": True}\n' \ + 'dummy = {"saspath": "/dummy/path/to/sas", "options": ["-dummy", "-options"]}\n' + + self.addCleanup(lambda: self._reset_config_modules()) + self._backup_paths(PATHS) + self.addCleanup(lambda: self._restore_paths(PATHS)) + + # Test that nothing is found if no config files are present + self._reset_config_modules() + with self.assertRaises(ImportError): + cfg_manager = saspy.SASconfig() + cfg_module = cfg_manager._find_config() + + # Test Option 5: Global `sascfg.py` file + # Create a temporary config file to simulate the presence of a config file at the global `sascfg.py` location + with open(self.cfg_global_standard_path, 'w') as f: + f.write(dummy_config_content) + self.addCleanup(lambda: os.remove(self.cfg_global_standard_path)) + self._reset_config_modules() + cfg_manager = saspy.SASconfig() + cfg_module = cfg_manager._find_config() + + cfg_src = inspect.getfile(cfg_module) + + self.assertEqual(cfg_src, self.cfg_global_standard_path) + + # Test Option 4: Home directory `sascfg_personal.py` file + # Create a temporary config file to simulate the presence of a config file at the home directory `sascfg_personal.py` location + with open(self.cfg_home_path, 'w') as f: + f.write(dummy_config_content) + self.addCleanup(lambda: os.remove(self.cfg_home_path)) + self._reset_config_modules() + cfg_manager = saspy.SASconfig() + cfg_module = cfg_manager._find_config() + + cfg_src = inspect.getfile(cfg_module) + + self.assertEqual(cfg_src, self.cfg_home_path) + + # Test Option 3: 'Local scope' sascfg_personal.py file + # Create a temporary config file to simulate the presence of a config file at the 'local scope' sascfg_personal.py location + with open(local_scope_path, 'w') as f: + f.write(dummy_config_content) + self.addCleanup(lambda: os.remove(local_scope_path)) + self._reset_config_modules() + cfg_manager = saspy.SASconfig() + cfg_module = cfg_manager._find_config() + + cfg_src = inspect.getfile(cfg_module) + + self.assertEqual(cfg_src, local_scope_path) + + # Test Option 2: Global `sascfg_personal.py` file + # Create a temporary config file to simulate the presence of a config file at the global `sascfg_personal.py` location + with open(self.cfg_global_personal_path, 'w') as f: + f.write(dummy_config_content) + self.addCleanup(lambda: os.remove(self.cfg_global_personal_path)) + + self._reset_config_modules() + cfg_manager = saspy.SASconfig() + cfg_module = cfg_manager._find_config() + + cfg_src = inspect.getfile(cfg_module) + + self.assertEqual(cfg_src, self.cfg_global_personal_path) + + # Test Option 1: Config file passed as a parameter to `_find_config` + # Create a temporary config file to simulate the presence of a config file at a custom location + tmpdir = tempfile.TemporaryDirectory() + tmpcfg = os.path.join(tmpdir.name, 'saspy_test_config.py') + with open(tmpcfg, 'w') as f: + f.write(dummy_config_content) + self.addCleanup(lambda: tmpdir.cleanup()) + + self._reset_config_modules() + cfg_manager = saspy.SASconfig() + cfg_module = cfg_manager._find_config(tmpcfg) + + cfg_src = inspect.getfile(cfg_module) + + # This is a special case where the cfg_src will be a copy of the temp file due to the + # way the _find_config function works, so we will check cfg_manager.origin instead of the file. + self.assertEqual(cfg_manager.origin, tmpcfg) + + # Now that the tests are complete, the cleanup functions will be called to + # remove any temporary config files that were created and restore any original + # config files that were moved/hidden. + + + \ No newline at end of file diff --git a/saspy/tests/test_sasexceptions.py b/saspy/tests/test_sasexceptions.py index 5b2d1f33..bf7cca78 100644 --- a/saspy/tests/test_sasexceptions.py +++ b/saspy/tests/test_sasexceptions.py @@ -41,28 +41,28 @@ def setUpClass(cls): path, not a file-like object when passed a `cfgfile`. """ # STDIO config file - with tempfile.NamedTemporaryFile("w", delete=False) as tf: + with tempfile.NamedTemporaryFile('w', suffix='.py', delete=False) as tf: tf.write(CONFIG_STDIO) cls.config_stdio = tf.name # SSH config file - with tempfile.NamedTemporaryFile("w", delete=False) as tf: + with tempfile.NamedTemporaryFile('w', suffix='.py', delete=False) as tf: tf.write(CONFIG_SSH) cls.config_ssh = tf.name # Windows IOM config file - with tempfile.NamedTemporaryFile("w", delete=False) as tf: + with tempfile.NamedTemporaryFile('w', suffix='.py', delete=False) as tf: tf.write(CONFIG_IOMWIN) cls.config_iomwin = tf.name # Invalid config file - with tempfile.NamedTemporaryFile("w", delete=False) as tf: + with tempfile.NamedTemporaryFile('w', suffix='.py', delete=False) as tf: tf.write(CONFIG_INVALID) cls.config_invalid = tf.name # Empty config file - with tempfile.NamedTemporaryFile("w", delete=False) as tf: - tf.write("") + with tempfile.NamedTemporaryFile('w', suffix='.py', delete=False) as tf: + tf.write('') cls.config_empty = tf.name @classmethod diff --git a/saspy/version.py b/saspy/version.py index bcf4370d..3ec7f240 100644 --- a/saspy/version.py +++ b/saspy/version.py @@ -1 +1 @@ -__version__ = '5.108.5' +__version__ = '5.108.6'