Skip to content

Commit a992ae6

Browse files
Add SPINE_CONFIG_PATH support for config include resolution
- Added resolve_config_path() function with smart path resolution - Supports SPINE_CONFIG_PATH environment variable (colon-separated list) - Resolution order: absolute → relative → SPINE_CONFIG_PATH - Auto-adds .yaml/.yml extensions if not found - Relative paths take precedence over search paths - Added 5 comprehensive tests for all resolution scenarios - Updated config README with SPINE_CONFIG_PATH documentation
1 parent b711b2f commit a992ae6

File tree

3 files changed

+258
-7
lines changed

3 files changed

+258
-7
lines changed

src/spine/config/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,44 @@ model:
7272
head: !include model/head.yaml
7373
```
7474

75+
#### Include Path Resolution
76+
77+
SPINE searches for included files in this order:
78+
79+
1. **Absolute paths**: Used as-is if they exist
80+
2. **Relative paths**: Resolved relative to the including config file
81+
3. **SPINE_CONFIG_PATH**: Searches through directories specified in the `SPINE_CONFIG_PATH` environment variable
82+
83+
**Extension handling**: If a file isn't found, SPINE automatically tries adding `.yaml` or `.yml` extensions.
84+
85+
**Example with SPINE_CONFIG_PATH**:
86+
87+
```bash
88+
# Set shared config directories
89+
export SPINE_CONFIG_PATH="/usr/local/spine/configs:/home/user/shared_configs"
90+
91+
# Now configs can include files from these directories without absolute paths
92+
```
93+
94+
```yaml
95+
# config.yaml - can include files from SPINE_CONFIG_PATH
96+
include:
97+
- base/default.yaml # Found in /usr/local/spine/configs/base/default.yaml
98+
- detectors/icarus # Auto-adds .yaml, finds in /home/user/shared_configs/detectors/icarus.yaml
99+
100+
io:
101+
reader:
102+
batch_size: 32
103+
```
104+
105+
**Multiple search paths**: Separate paths with `:` (like `PATH` or `PYTHONPATH`):
106+
107+
```bash
108+
export SPINE_CONFIG_PATH="$HOME/.spine/configs:/opt/spine/configs:/shared/configs"
109+
```
110+
111+
**Path precedence**: Relative paths (from the config's directory) always take precedence over `SPINE_CONFIG_PATH` entries. This allows local overrides of shared configs.
112+
75113
### Parameter Overrides with `override:`
76114

77115
Override specific parameters using dot notation:

src/spine/config/loader.py

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,88 @@
7575
"extract_includes_and_overrides",
7676
"apply_collection_operation",
7777
"ConfigLoader",
78+
"resolve_config_path",
7879
]
7980

8081

82+
def resolve_config_path(
83+
filename: str, current_dir: str, search_paths: Optional[List[str]] = None
84+
) -> str:
85+
"""Resolve a configuration file path with SPINE_CONFIG_PATH support.
86+
87+
Resolution order:
88+
1. If absolute path, return as-is
89+
2. Try relative to current_dir
90+
3. Try relative to current_dir with .yaml/.yml extension
91+
4. Search through SPINE_CONFIG_PATH directories
92+
5. Search through SPINE_CONFIG_PATH directories with .yaml/.yml extension
93+
94+
Parameters
95+
----------
96+
filename : str
97+
Config filename or path to resolve
98+
current_dir : str
99+
Directory of the config file doing the including
100+
search_paths : Optional[List[str]]
101+
List of search paths (defaults to SPINE_CONFIG_PATH env var)
102+
103+
Returns
104+
-------
105+
str
106+
Resolved absolute path
107+
108+
Raises
109+
------
110+
ConfigIncludeError
111+
If file cannot be found in any location
112+
"""
113+
# If absolute path, check if it exists
114+
if os.path.isabs(filename):
115+
if os.path.exists(filename):
116+
return filename
117+
raise ConfigIncludeError(f"Absolute path not found: {filename}")
118+
119+
# Try relative to current directory
120+
relative_path = os.path.join(current_dir, filename)
121+
if os.path.exists(relative_path):
122+
return os.path.abspath(relative_path)
123+
124+
# Try with .yaml/.yml extension relative to current directory
125+
for ext in [".yaml", ".yml"]:
126+
if not filename.endswith(ext):
127+
relative_path_with_ext = relative_path + ext
128+
if os.path.exists(relative_path_with_ext):
129+
return os.path.abspath(relative_path_with_ext)
130+
131+
# Get search paths from environment variable if not provided
132+
if search_paths is None:
133+
env_paths = os.environ.get("SPINE_CONFIG_PATH", "")
134+
search_paths = [p.strip() for p in env_paths.split(":") if p.strip()]
135+
136+
# Search through SPINE_CONFIG_PATH
137+
for search_dir in search_paths:
138+
search_path = os.path.join(search_dir, filename)
139+
if os.path.exists(search_path):
140+
return os.path.abspath(search_path)
141+
142+
# Try with extensions
143+
for ext in [".yaml", ".yml"]:
144+
if not filename.endswith(ext):
145+
search_path_with_ext = search_path + ext
146+
if os.path.exists(search_path_with_ext):
147+
return os.path.abspath(search_path_with_ext)
148+
149+
# File not found anywhere
150+
search_locations = [f"Relative to: {current_dir}"]
151+
if search_paths:
152+
search_locations.append(f"SPINE_CONFIG_PATH: {':'.join(search_paths)}")
153+
154+
raise ConfigIncludeError(
155+
f"Config file '{filename}' not found.\n"
156+
f"Searched in:\n - " + "\n - ".join(search_locations)
157+
)
158+
159+
81160
class ConfigLoader(yaml.SafeLoader):
82161
"""YAML loader with !include tag support.
83162
@@ -109,11 +188,10 @@ def include(self, node: yaml.Node) -> Any:
109188
Any
110189
Loaded configuration content
111190
"""
112-
filename = os.path.join(
113-
self._root, self.construct_scalar(cast(yaml.ScalarNode, node))
114-
)
191+
filename = self.construct_scalar(cast(yaml.ScalarNode, node))
192+
resolved_path = resolve_config_path(filename, self._root)
115193

116-
with open(filename, "r", encoding="utf-8") as f:
194+
with open(resolved_path, "r", encoding="utf-8") as f:
117195
return yaml.load(f, Loader=ConfigLoader)
118196

119197

@@ -568,9 +646,8 @@ def _load_config_recursive(
568646

569647
# Process includes
570648
for include_file in includes:
571-
include_path = os.path.join(root_dir, include_file)
572-
if not os.path.exists(include_path):
573-
raise ConfigIncludeError(f"Included file not found: {include_path}")
649+
# Resolve include path with SPINE_CONFIG_PATH support
650+
include_path = resolve_config_path(include_file, root_dir)
574651

575652
# Recursively load
576653
(

test/test_config.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1287,3 +1287,139 @@ def test_override_creates_final_key_if_parent_exists(self, tmp_path):
12871287
# new_key should be created because io.reader exists
12881288
assert cfg["io"]["reader"]["new_key"] == "new_value"
12891289
assert cfg["io"]["reader"]["batch_size"] == 4
1290+
1291+
def test_spine_config_path(self, tmp_path, monkeypatch):
1292+
"""Test SPINE_CONFIG_PATH environment variable for include resolution."""
1293+
# Create a shared config directory
1294+
shared_dir = tmp_path / "shared_configs"
1295+
shared_dir.mkdir()
1296+
1297+
# Create a shared base config
1298+
shared_base = shared_dir / "shared_base.yaml"
1299+
shared_base.write_text(
1300+
"""
1301+
base:
1302+
world_size: 1
1303+
iterations: 1000
1304+
"""
1305+
)
1306+
1307+
# Create a local config directory
1308+
local_dir = tmp_path / "local"
1309+
local_dir.mkdir()
1310+
1311+
# Create a local config that includes the shared base
1312+
local_config = local_dir / "config.yaml"
1313+
local_config.write_text(
1314+
"""
1315+
include: shared_base.yaml
1316+
1317+
io:
1318+
reader:
1319+
batch_size: 32
1320+
"""
1321+
)
1322+
1323+
# Set SPINE_CONFIG_PATH
1324+
monkeypatch.setenv("SPINE_CONFIG_PATH", str(shared_dir))
1325+
1326+
# Load the config - should find shared_base.yaml via SPINE_CONFIG_PATH
1327+
cfg = load_config(str(local_config))
1328+
1329+
assert cfg["base"]["world_size"] == 1
1330+
assert cfg["base"]["iterations"] == 1000
1331+
assert cfg["io"]["reader"]["batch_size"] == 32
1332+
1333+
def test_spine_config_path_multiple_dirs(self, tmp_path, monkeypatch):
1334+
"""Test SPINE_CONFIG_PATH with multiple search directories."""
1335+
# Create multiple config directories
1336+
shared_dir1 = tmp_path / "shared1"
1337+
shared_dir1.mkdir()
1338+
shared_dir2 = tmp_path / "shared2"
1339+
shared_dir2.mkdir()
1340+
1341+
# Create configs in different directories
1342+
config1 = shared_dir1 / "config1.yaml"
1343+
config1.write_text("base:\n value1: 100")
1344+
1345+
config2 = shared_dir2 / "config2.yaml"
1346+
config2.write_text("io:\n value2: 200")
1347+
1348+
# Create local config
1349+
local_dir = tmp_path / "local"
1350+
local_dir.mkdir()
1351+
local_config = local_dir / "main.yaml"
1352+
local_config.write_text(
1353+
"""
1354+
include:
1355+
- config1.yaml
1356+
- config2.yaml
1357+
"""
1358+
)
1359+
1360+
# Set SPINE_CONFIG_PATH with multiple paths
1361+
monkeypatch.setenv("SPINE_CONFIG_PATH", f"{shared_dir1}:{shared_dir2}")
1362+
1363+
cfg = load_config(str(local_config))
1364+
1365+
assert cfg["base"]["value1"] == 100
1366+
assert cfg["io"]["value2"] == 200
1367+
1368+
def test_spine_config_path_auto_extension(self, tmp_path, monkeypatch):
1369+
"""Test automatic .yaml extension addition."""
1370+
shared_dir = tmp_path / "shared"
1371+
shared_dir.mkdir()
1372+
1373+
shared_base = shared_dir / "base.yaml"
1374+
shared_base.write_text("base:\n value: 42")
1375+
1376+
local_dir = tmp_path / "local"
1377+
local_dir.mkdir()
1378+
local_config = local_dir / "config.yaml"
1379+
# Include without extension
1380+
local_config.write_text("include: base")
1381+
1382+
monkeypatch.setenv("SPINE_CONFIG_PATH", str(shared_dir))
1383+
1384+
cfg = load_config(str(local_config))
1385+
assert cfg["base"]["value"] == 42
1386+
1387+
def test_spine_config_path_relative_takes_precedence(self, tmp_path, monkeypatch):
1388+
"""Test that relative paths take precedence over SPINE_CONFIG_PATH."""
1389+
# Create shared directory
1390+
shared_dir = tmp_path / "shared"
1391+
shared_dir.mkdir()
1392+
shared_config = shared_dir / "config.yaml"
1393+
shared_config.write_text("value: shared")
1394+
1395+
# Create local directory with same-named file
1396+
local_dir = tmp_path / "local"
1397+
local_dir.mkdir()
1398+
local_config = local_dir / "config.yaml"
1399+
local_config.write_text("value: local")
1400+
1401+
# Create main config that includes config.yaml
1402+
main_config = local_dir / "main.yaml"
1403+
main_config.write_text("include: config.yaml")
1404+
1405+
# Set SPINE_CONFIG_PATH to shared dir
1406+
monkeypatch.setenv("SPINE_CONFIG_PATH", str(shared_dir))
1407+
1408+
# Should load local version (relative path takes precedence)
1409+
cfg = load_config(str(main_config))
1410+
assert cfg["value"] == "local"
1411+
1412+
def test_spine_config_path_not_found(self, tmp_path, monkeypatch):
1413+
"""Test error when config not found in SPINE_CONFIG_PATH."""
1414+
shared_dir = tmp_path / "shared"
1415+
shared_dir.mkdir()
1416+
1417+
local_dir = tmp_path / "local"
1418+
local_dir.mkdir()
1419+
local_config = local_dir / "main.yaml"
1420+
local_config.write_text("include: nonexistent.yaml")
1421+
1422+
monkeypatch.setenv("SPINE_CONFIG_PATH", str(shared_dir))
1423+
1424+
with pytest.raises(ConfigIncludeError, match="not found"):
1425+
load_config(str(local_config))

0 commit comments

Comments
 (0)