diff --git a/docs/source/config.rst b/docs/source/config.rst index d79af5e5..8398006a 100644 --- a/docs/source/config.rst +++ b/docs/source/config.rst @@ -291,14 +291,13 @@ Configuration files currently can be a plain Python script or a JSON file. The former has the benefit that it can perform extensive logic to populate the config object, while the latter is just a direct JSON serialization of the config dictionary and can be easily processed by external software. -When both Python and JSON configuration file are present, both will be loaded, -with JSON configuration having higher priority. - -The configuration files can be loaded by calling :meth:`Application.load_config_file()`, -which takes the relative path to the config file (with or without file extension) -and the directories in which to search for the config file. All found configuration -files will be loaded in reverse order, so that configs in earlier directories will -have higher priority. +Configuration files can be loaded by calling :meth:`Application.load_config_file()`, +which accepts a relative or absolute filename and directories in which to search. +When no extension is specified, both Python and JSON configuration files are loaded +if present, with JSON having higher priority. When a ``.py`` or ``.json`` extension +is explicitly specified, only files with that extension are loaded. +Configuration files are loaded in reverse directory order, so configurations in +earlier directories have higher priority. Python configuration Files diff --git a/tests/config/test_config_file_extension.py b/tests/config/test_config_file_extension.py new file mode 100644 index 00000000..6dd9edca --- /dev/null +++ b/tests/config/test_config_file_extension.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from unittest import TestCase + +import pytest + +from traitlets import Integer +from traitlets.config import Application + + +class ExampleApp(Application): + value = Integer(0).tag(config=True) + + +@pytest.fixture +def config_paths(tmp_path): + py_path = tmp_path / "foo.py" + json_path = tmp_path / "foo.json" + py_path.write_text("c.ExampleApp.value = 10\n", encoding="utf-8") + json_path.write_text('{"ExampleApp": {"value": 11}}', encoding="utf-8") + return py_path, json_path + + +@pytest.mark.parametrize("suffix,value", [(".py", 10), (".json", 11)]) +def test_explicit_absolute_path_only_loads_requested_file(config_paths, suffix, value): + py_path, json_path = config_paths + requested = py_path if suffix == ".py" else json_path + app = ExampleApp(raise_config_file_errors=True) + app.load_config_file(requested) + assert app.value == value + assert app.loaded_config_files == [str(requested)] + + +@pytest.mark.parametrize("suffix,value", [(".py", 10), (".json", 11)]) +def test_explicit_relative_path_only_loads_requested_file(config_paths, suffix, value): + py_path, json_path = config_paths + requested = py_path if suffix == ".py" else json_path + app = ExampleApp(raise_config_file_errors=True) + app.load_config_file(requested.name, path=str(requested.parent)) + assert app.value == value + assert app.loaded_config_files == [str(requested)] + + +def test_explicit_python_ignores_broken_json(config_paths): + py_path, json_path = config_paths + json_path.write_text("Invalid JSON", encoding="utf-8") + app = ExampleApp(raise_config_file_errors=True) + app.load_config_file(py_path) + assert app.value == 10 + assert app.loaded_config_files == [str(py_path)] + + +def test_explicit_json_never_executes_python(config_paths): + py_path, json_path = config_paths + py_path.write_text("raise RuntimeError('unexpected Python execution')", encoding="utf-8") + app = ExampleApp(raise_config_file_errors=True) + app.load_config_file(json_path) + assert app.value == 11 + assert app.loaded_config_files == [str(json_path)] + + +def test_basename_loads_both_in_legacy_order(config_paths): + py_path, json_path = config_paths + app = ExampleApp(raise_config_file_errors=True) + app.load_config_file(str(py_path.with_suffix(""))) + assert app.value == 11 + assert app.loaded_config_files == [str(py_path), str(json_path)] + + +def test_dotted_basename_explicit_file(tmp_path): + py_path = tmp_path / "app.v1.py" + json_path = tmp_path / "app.v1.json" + py_path.write_text("c.ExampleApp.value = 10", encoding="utf-8") + json_path.write_text('{"ExampleApp": {"value": 11}}', encoding="utf-8") + app = ExampleApp(raise_config_file_errors=True) + app.load_config_file(py_path) + assert app.value == 10 + assert app.loaded_config_files == [str(py_path)] + + +def test_path_priority_not_changed(tmp_path): + left = tmp_path / "left" + right = tmp_path / "right" + left.mkdir() + right.mkdir() + (left / "foo.py").write_text("c.ExampleApp.value = 10", encoding="utf-8") + (right / "foo.py").write_text("c.ExampleApp.value = 20", encoding="utf-8") + (left / "foo.json").write_text('{"ExampleApp": {"value": 11}}', encoding="utf-8") + (right / "foo.json").write_text('{"ExampleApp": {"value": 21}}', encoding="utf-8") + app = ExampleApp(raise_config_file_errors=True) + app.load_config_file("foo.py", path=[str(left), str(right)]) + assert app.value == 10 + assert app.loaded_config_files == [str(right / "foo.py"), str(left / "foo.py")] + + +def test_legacy_unknown_extension_behavior(tmp_path): + # Before #850, unknown suffixes were stripped in load_config_file(). + py_path = tmp_path / "foo.py" + py_path.write_text("c.ExampleApp.value = 10", encoding="utf-8") + app = ExampleApp(raise_config_file_errors=True) + app.load_config_file(tmp_path / "foo.unknown") + assert app.value == 10 + assert app.loaded_config_files == [str(py_path)] + + +def test_basename_collision_warning(config_paths): + py_path, _ = config_paths + app = ExampleApp(raise_config_file_errors=True) + with TestCase().assertLogs(app.log, level="WARNING") as captured: + app.load_config_file("foo", path=str(py_path.parent)) + assert app.value == 11 + assert any("Collisions detected" in line for line in captured.output) + + +def test_explicit_bad_python_still_raises(tmp_path): + bad_py = tmp_path / "broken.py" + bad_py.write_text("syntax error()", encoding="utf-8") + app = ExampleApp(raise_config_file_errors=True) + with pytest.raises(SyntaxError): + app.load_config_file(bad_py) diff --git a/traitlets/config/application.py b/traitlets/config/application.py index 2027bcb1..fcc02ee1 100644 --- a/traitlets/config/application.py +++ b/traitlets/config/application.py @@ -901,19 +901,29 @@ def _load_config_files( yield each config object in turn. """ + # Only select a single loader when an explicit supported suffix is given. + extension = os.path.splitext(basefilename)[1] + if extension in (".py", ".json"): + basefilename = basefilename[: -len(extension)] + else: + extension = "" if os.path.isabs(basefilename): path = [None] if isinstance(path, str) or path is None: path = [path] for current in reversed(path): # path list is in descending priority order, so load files backwards: - pyloader = cls.python_config_loader_class(basefilename + ".py", path=current, log=log) if log: log.debug("Looking for %s in %s", basefilename, current or os.getcwd()) - jsonloader = cls.json_config_loader_class(basefilename + ".json", path=current, log=log) loaded: list[t.Any] = [] filenames: list[str] = [] - for loader in [pyloader, jsonloader]: + for suffix, loader_class in ( + (".py", cls.python_config_loader_class), + (".json", cls.json_config_loader_class), + ): + if extension and extension != suffix: + continue + loader = loader_class(basefilename + suffix, path=current, log=log) config = None try: config = loader.load_config() @@ -959,7 +969,11 @@ def load_config_file( self, filename: str, path: str | t.Sequence[str | None] | None = None ) -> None: """Load config files by filename and path.""" - filename, _ext = os.path.splitext(filename) + # Keep .py/.json extensions to avoid loading the other file type. + # Other extensions retain the previous basename-only behavior. + filename, ext = os.path.splitext(filename) + if ext in (".py", ".json"): + filename += ext new_config = Config() for config, fname in self._load_config_files( filename,