Skip to content
Merged
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
45 changes: 30 additions & 15 deletions src/gpuma/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import annotations

import copy
import functools
import json
import logging
import os
Expand Down Expand Up @@ -151,38 +152,49 @@ def from_dict(cls, data: dict[str, Any]) -> Config:
return cls(data)


def load_config_from_file(filepath: str = "config.json") -> Config:
"""Load configuration from a JSON/YAML file and deep-merge with defaults.

Args:
filepath: Path to the config file. If it doesn't exist, defaults are used.
@functools.lru_cache(maxsize=1)
def _read_config_file(filepath: str) -> dict[str, Any]:
"""Read and parse configuration file with caching.

Returns:
A :class:`Config` instance with data merged with :data:`DEFAULT_CONFIG`.
Unknown keys are preserved.
Returns an empty dict if the file does not exist.
"""
if not os.path.exists(filepath):
cfg = Config()
validate_config(cfg)
return cfg
return {}

with open(filepath, encoding="utf-8") as f:
if filepath.endswith(".json"):
user_cfg = json.load(f)
return json.load(f)
elif filepath.endswith(".yaml") or filepath.endswith(".yml"):
try:
from yaml import safe_load as _yaml_safe_load # type: ignore

user_cfg = _yaml_safe_load(f)
return _yaml_safe_load(f)
except ImportError as e:
raise ImportError("PyYAML is required to load YAML config files") from e
else:
raise ValueError("Config file must be JSON or YAML format")

if not isinstance(user_cfg, dict):

def load_config_from_file(filepath: str = "config.json") -> Config:
"""Load configuration from a JSON/YAML file and deep-merge with defaults.

This function caches the raw dictionary loaded from the file to avoid
repeated I/O and parsing. The returned Config object is always a new
instance, safe to modify.

Args:
filepath: Path to the config file. If it doesn't exist, defaults are used.

Returns:
A :class:`Config` instance with data merged with :data:`DEFAULT_CONFIG`.
Unknown keys are preserved.
"""
user_cfg = _read_config_file(filepath)

if user_cfg is not None and not isinstance(user_cfg, dict):
raise ValueError("Configuration file must contain a JSON/YAML object at the root")

cfg = Config.from_dict(user_cfg)
cfg = Config.from_dict(copy.deepcopy(user_cfg))
validate_config(cfg)
return cfg

Expand All @@ -207,6 +219,9 @@ def save_config_to_file(config: Any, filepath: str) -> None:
else:
raise ValueError("Config file must be JSON or YAML format")

# Invalidate cache since the file on disk has changed
_read_config_file.cache_clear()


def get_huggingface_token(config: Config | dict[str, Any]) -> str | None:
"""Return the HuggingFace token from config or a file, if available.
Expand Down
34 changes: 34 additions & 0 deletions tests/test_config_module.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import logging
import os
import json
from unittest.mock import patch, mock_open

import pytest
import torch
Expand All @@ -11,6 +14,7 @@
load_config_from_file,
save_config_to_file,
validate_config,
_read_config_file,
)


Expand Down Expand Up @@ -103,3 +107,33 @@ def test_validate_config_device_and_charge_multiplicity():
cfg.optimization.multiplicity = 0
with pytest.raises(ValueError):
validate_config(cfg)


def test_load_config_caching_behavior(tmp_path):
config_path = tmp_path / "config.json"
with open(config_path, "w") as f:
json.dump({"optimization": {"model_name": "cached"}}, f)

# Clear cache before starting
_read_config_file.cache_clear()

# First load
cfg1 = load_config_from_file(str(config_path))
assert cfg1.optimization.model_name == "cached"

# Modify file directly on disk
with open(config_path, "w") as f:
json.dump({"optimization": {"model_name": "modified"}}, f)

# Second load - should still see "cached" because it's cached
cfg2 = load_config_from_file(str(config_path))
assert cfg2.optimization.model_name == "cached"

# Now verify save_config_to_file clears cache
new_cfg = Config()
new_cfg.optimization.model_name = "saved"
save_config_to_file(new_cfg, str(config_path))

# Third load - should see "saved" because cache was cleared
cfg3 = load_config_from_file(str(config_path))
assert cfg3.optimization.model_name == "saved"