Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions docs/source/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions tests/config/test_config_file_extension.py
Original file line number Diff line number Diff line change
@@ -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)
22 changes: 18 additions & 4 deletions traitlets/config/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
Loading