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.
This commit is contained in:
hikmetba-bit 2026-09-20 09:46:19 +03:00
commit 857833b94e
2 changed files with 36 additions and 0 deletions

View file

@ -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)

View file

@ -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 = {