From 857833b94eb5b8e91f0daadac55717637125528e Mon Sep 17 00:00:00 2001 From: hikmetba-bit Date: Sun, 20 Sep 2026 09:46:19 +0300 Subject: [PATCH] Raise a clear error for a missing/unsupported settings file load_source() passed the path straight to importlib.util.spec_from_file_location() and then accessed spec.loader without checking spec first. When the path has no extension or an extension importlib doesn't recognize as a source file (e.g. the user passed 'publishconf' instead of 'publishconf.py'), spec_from_file_location returns None, and the subsequent module_from_spec(None) raises a cryptic "AttributeError: 'NoneType' object has no attribute 'loader'" that names neither the missing file nor its path. Check the file exists first and raise FileNotFoundError naming the path; if it exists but importlib still can't build a loader for it, raise ImportError explaining the file type isn't supported. Fixes #3614. --- pelican/settings.py | 8 ++++++++ pelican/tests/test_settings.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/pelican/settings.py b/pelican/settings.py index 45957fd0..fd4aed90 100644 --- a/pelican/settings.py +++ b/pelican/settings.py @@ -16,7 +16,15 @@ from pelican.paginator import PaginationRule def load_source(name: str, path: str) -> ModuleType: + if not os.path.isfile(path): + raise FileNotFoundError(f"Settings file not found: {path}") + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise ImportError( + f"Unable to load settings file (unsupported file type): {path}" + ) + mod = importlib.util.module_from_spec(spec) sys.modules[name] = mod spec.loader.exec_module(mod) diff --git a/pelican/tests/test_settings.py b/pelican/tests/test_settings.py index 54167e63..210d42fc 100644 --- a/pelican/tests/test_settings.py +++ b/pelican/tests/test_settings.py @@ -11,6 +11,7 @@ from pelican.settings import ( _printf_s_to_format_field, configure_settings, handle_deprecated_settings, + load_source, read_settings, ) from pelican.tests.support import LogCountHandler, unittest @@ -73,6 +74,33 @@ class TestSettingsConfiguration(unittest.TestCase): settings["SITENAME"] = "Not a Pelican Blog" self.assertNotEqual(settings["SITENAME"], DEFAULT_CONFIG["SITENAME"]) + def test_load_source_missing_file_reports_path(self): + # A missing settings file should raise a clear error naming the + # path, not the cryptic "'NoneType' object has no attribute + # 'loader'" that importlib raises when spec_from_file_location + # can't determine a loader (e.g. no/unknown file extension). + missing_path = join(self.PATH, "this_settings_file_does_not_exist") + with self.assertRaises(FileNotFoundError) as ctx: + load_source("test_missing", missing_path) + self.assertIn(missing_path, str(ctx.exception)) + + def test_load_source_missing_file_with_extension_reports_path(self): + missing_path = join(self.PATH, "this_settings_file_does_not_exist.py") + with self.assertRaises(FileNotFoundError) as ctx: + load_source("test_missing_py", missing_path) + self.assertIn(missing_path, str(ctx.exception)) + + def test_load_source_unsupported_extension_reports_path(self): + unsupported_path = join(self.PATH, "default_conf.py.bak") + with open(unsupported_path, "w") as f: + f.write("SITENAME = 'unused'\n") + try: + with self.assertRaises(ImportError) as ctx: + load_source("test_unsupported", unsupported_path) + self.assertIn(unsupported_path, str(ctx.exception)) + finally: + os.remove(unsupported_path) + def test_static_path_settings_safety(self): # Disallow static paths from being strings settings = {