mirror of
https://github.com/openmc-dev/openmc.git
synced 2026-07-27 21:55:41 -04:00
Refactor and Harden Configuration Management (#3461)
Co-authored-by: Paul Romano <paul.k.romano@gmail.com>
This commit is contained in:
parent
c116288ea3
commit
25d64c9b26
2 changed files with 247 additions and 98 deletions
230
openmc/config.py
230
openmc/config.py
|
|
@ -1,8 +1,23 @@
|
|||
"""Module for handling global configuration in OpenMC.
|
||||
|
||||
This module exports a single object, `config`, that can be used to control
|
||||
various settings, primarily paths to data files. It acts like a dictionary but
|
||||
with special behaviors.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import openmc
|
||||
>>> openmc.config['cross_sections'] = '/path/to/my/cross_sections.xml'
|
||||
>>> print(openmc.config)
|
||||
{'resolve_paths': True, 'cross_sections': PosixPath('/path/to/my/cross_sections.xml')}
|
||||
|
||||
"""
|
||||
from collections.abc import MutableMapping
|
||||
from contextlib import contextmanager
|
||||
import os
|
||||
from pathlib import Path
|
||||
import warnings
|
||||
from typing import Any, Dict, Iterator
|
||||
|
||||
from openmc.data import DataLibrary
|
||||
from openmc.data.decay import _DECAY_ENERGY, _DECAY_PHOTON_ENERGY
|
||||
|
|
@ -11,104 +26,195 @@ __all__ = ["config"]
|
|||
|
||||
|
||||
class _Config(MutableMapping):
|
||||
def __init__(self, data=()):
|
||||
self._mapping = {'resolve_paths': True}
|
||||
"""A configuration dictionary for OpenMC with special handling for path-like values.
|
||||
|
||||
This class enforces valid configuration keys and synchronizes path-related
|
||||
settings with their corresponding environment variables.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
cross_sections : pathlib.Path
|
||||
Path to a cross_sections.xml file. Also sets/unsets the
|
||||
OPENMC_CROSS_SECTIONS environment variable.
|
||||
mg_cross_sections : pathlib.Path
|
||||
Path to a multi-group cross_sections.h5 file. Also sets/unsets
|
||||
the OPENMC_MG_CROSS_SECTIONS environment variable.
|
||||
chain_file : pathlib.Path
|
||||
Path to a depletion chain XML file. Also sets/unsets the
|
||||
OPENMC_CHAIN_FILE environment variable. Setting or deleting this
|
||||
clears internal decay data caches.
|
||||
resolve_paths : bool
|
||||
If True (default), all paths assigned are resolved to absolute
|
||||
paths. If False, paths are stored as they are provided.
|
||||
|
||||
"""
|
||||
_PATH_KEYS: Dict[str, str] = {
|
||||
'cross_sections': 'OPENMC_CROSS_SECTIONS',
|
||||
'mg_cross_sections': 'OPENMC_MG_CROSS_SECTIONS',
|
||||
'chain_file': 'OPENMC_CHAIN_FILE'
|
||||
}
|
||||
|
||||
def __init__(self, data: dict = ()):
|
||||
self._mapping: Dict[str, Any] = {'resolve_paths': True}
|
||||
self.update(data)
|
||||
|
||||
def __getitem__(self, key):
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
return self._mapping[key]
|
||||
|
||||
def __delitem__(self, key):
|
||||
del self._mapping[key]
|
||||
if key == 'cross_sections':
|
||||
del os.environ['OPENMC_CROSS_SECTIONS']
|
||||
elif key == 'mg_cross_sections':
|
||||
del os.environ['OPENMC_MG_CROSS_SECTIONS']
|
||||
elif key == 'chain_file':
|
||||
del os.environ['OPENMC_CHAIN_FILE']
|
||||
# Reset photon source data since it relies on chain file
|
||||
_DECAY_PHOTON_ENERGY.clear()
|
||||
def __delitem__(self, key: str):
|
||||
"""Delete a configuration key.
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
if key == 'cross_sections':
|
||||
# Force environment variable to match
|
||||
self._set_path(key, value)
|
||||
os.environ['OPENMC_CROSS_SECTIONS'] = str(value)
|
||||
elif key == 'mg_cross_sections':
|
||||
self._set_path(key, value)
|
||||
os.environ['OPENMC_MG_CROSS_SECTIONS'] = str(value)
|
||||
elif key == 'chain_file':
|
||||
self._set_path(key, value)
|
||||
os.environ['OPENMC_CHAIN_FILE'] = str(value)
|
||||
# Reset photon source data since it relies on chain file
|
||||
This also deletes the corresponding environment variable if the key is a
|
||||
path-like key, and clears decay data caches if 'chain_file' is deleted.
|
||||
'resolve_paths' cannot be deleted.
|
||||
|
||||
"""
|
||||
if key == 'resolve_paths':
|
||||
raise KeyError("'resolve_paths' cannot be deleted.")
|
||||
del self._mapping[key]
|
||||
if key in self._PATH_KEYS:
|
||||
env_var = self._PATH_KEYS[key]
|
||||
if env_var in os.environ:
|
||||
del os.environ[env_var]
|
||||
if key == 'chain_file':
|
||||
_DECAY_PHOTON_ENERGY.clear()
|
||||
_DECAY_ENERGY.clear()
|
||||
|
||||
def __setitem__(self, key: str, value: Any):
|
||||
"""Set a configuration key and its corresponding value.
|
||||
|
||||
For path-like keys, this method performs several actions:
|
||||
1. Resolves the path to an absolute path if `resolve_paths` is True.
|
||||
2. Stores the `pathlib.Path` object.
|
||||
3. Sets the corresponding environment variable (e.g., OPENMC_CROSS_SECTIONS).
|
||||
4. For 'chain_file', clears internal decay data caches.
|
||||
5. Issues a `UserWarning` if the final path does not exist.
|
||||
|
||||
"""
|
||||
if key in self._PATH_KEYS:
|
||||
p = Path(value)
|
||||
# Use .get() for robustness, defaulting to True
|
||||
if self._mapping.get('resolve_paths', True):
|
||||
stored_path = p.resolve(strict=False)
|
||||
else:
|
||||
stored_path = p
|
||||
|
||||
self._mapping[key] = stored_path
|
||||
os.environ[self._PATH_KEYS[key]] = str(stored_path)
|
||||
|
||||
if key == 'chain_file':
|
||||
_DECAY_PHOTON_ENERGY.clear()
|
||||
_DECAY_ENERGY.clear()
|
||||
|
||||
if not stored_path.exists():
|
||||
warnings.warn(f"Path '{stored_path}' does not exist.", UserWarning)
|
||||
|
||||
elif key == 'resolve_paths':
|
||||
if not isinstance(value, bool):
|
||||
raise TypeError("'resolve_paths' must be a boolean.")
|
||||
self._mapping[key] = value
|
||||
else:
|
||||
raise KeyError(f'Unrecognized config key: {key}. Acceptable keys '
|
||||
'are "cross_sections", "mg_cross_sections", '
|
||||
'"chain_file", and "resolve_paths".')
|
||||
valid_keys = list(self._PATH_KEYS.keys()) + ['resolve_paths']
|
||||
raise KeyError(
|
||||
f"Unrecognized config key: {key}. Acceptable keys are: "
|
||||
f"{', '.join(repr(k) for k in valid_keys)}."
|
||||
)
|
||||
|
||||
def __iter__(self):
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self._mapping)
|
||||
|
||||
def __len__(self):
|
||||
def __len__(self) -> int:
|
||||
return len(self._mapping)
|
||||
|
||||
def __repr__(self):
|
||||
def __repr__(self) -> str:
|
||||
return repr(self._mapping)
|
||||
|
||||
def _set_path(self, key, value):
|
||||
self._mapping[key] = p = Path(value).resolve()
|
||||
if not p.exists():
|
||||
warnings.warn(f"'{value}' does not exist.")
|
||||
def clear(self):
|
||||
"""Clear all configuration keys except for 'resolve_paths'.
|
||||
|
||||
This ensures that the path resolution behavior is not accidentally reset
|
||||
when clearing the configuration.
|
||||
|
||||
"""
|
||||
# Create a copy of keys to iterate over for safe deletion
|
||||
keys_to_delete = [k for k in self._mapping if k != 'resolve_paths']
|
||||
for key in keys_to_delete:
|
||||
del self[key]
|
||||
|
||||
@contextmanager
|
||||
def patch(self, key, value):
|
||||
"""Temporarily change a value in the configuration.
|
||||
def patch(self, key: str, value: Any):
|
||||
"""Context manager to temporarily change a configuration value.
|
||||
|
||||
After the `with` block, the configuration is restored to its original
|
||||
state.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key : str
|
||||
Key to change
|
||||
value : object
|
||||
New value
|
||||
The key of the configuration value to change.
|
||||
value
|
||||
The new temporary value.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> openmc.config['cross_sections'] = 'endf71.xml'
|
||||
>>> with openmc.config.patch('cross_sections', 'fendl32.xml'):
|
||||
... # Code in this block sees the new value
|
||||
... print(f"Inside with block: {openmc.config['cross_sections']}")
|
||||
>>> # Outside the block, the value is restored
|
||||
>>> print(f"Outside with block: {openmc.config['cross_sections']}")
|
||||
Inside with block: fendl32.xml
|
||||
Outside with block: endf71.xml
|
||||
|
||||
"""
|
||||
previous_value = self.get(key)
|
||||
self[key] = value
|
||||
yield
|
||||
if previous_value is None:
|
||||
del self[key]
|
||||
else:
|
||||
self[key] = previous_value
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if previous_value is None:
|
||||
del self[key]
|
||||
else:
|
||||
self[key] = previous_value
|
||||
|
||||
def _default_config():
|
||||
"""Return default configuration"""
|
||||
|
||||
def _default_config() -> _Config:
|
||||
"""Create a configuration initialized from environment variables.
|
||||
|
||||
This function checks for OPENMC_CROSS_SECTIONS, OPENMC_MG_CROSS_SECTIONS,
|
||||
and OPENMC_CHAIN_FILE environment variables. It also has logic to find
|
||||
a chain file within a `cross_sections.xml` file if one is not
|
||||
explicitly set.
|
||||
|
||||
Returns
|
||||
-------
|
||||
_Config
|
||||
A new configuration object.
|
||||
|
||||
"""
|
||||
config = _Config()
|
||||
|
||||
# Set cross sections using environment variable
|
||||
if "OPENMC_CROSS_SECTIONS" in os.environ:
|
||||
config['cross_sections'] = os.environ["OPENMC_CROSS_SECTIONS"]
|
||||
if "OPENMC_MG_CROSS_SECTIONS" in os.environ:
|
||||
config['mg_cross_sections'] = os.environ["OPENMC_MG_CROSS_SECTIONS"]
|
||||
|
||||
# Set depletion chain
|
||||
chain_file = os.environ.get("OPENMC_CHAIN_FILE")
|
||||
if (chain_file is None and
|
||||
config.get('cross_sections') is not None and
|
||||
config['cross_sections'].exists()
|
||||
):
|
||||
# Check for depletion chain in cross_sections.xml
|
||||
data = DataLibrary.from_xml(config['cross_sections'])
|
||||
for lib in reversed(data.libraries):
|
||||
if lib['type'] == 'depletion_chain':
|
||||
chain_file = lib['path']
|
||||
break
|
||||
xs_path = config.get('cross_sections')
|
||||
if chain_file is None and xs_path is not None and xs_path.exists():
|
||||
try:
|
||||
data = DataLibrary.from_xml(xs_path)
|
||||
except Exception:
|
||||
# Let this pass silently if cross_sections.xml can't be parsed
|
||||
# or if a dependency like lxml is not available.
|
||||
pass
|
||||
else:
|
||||
for lib in reversed(data.libraries):
|
||||
if lib['type'] == 'depletion_chain':
|
||||
chain_file = xs_path.parent / lib['path']
|
||||
break
|
||||
if chain_file is not None:
|
||||
config['chain_file'] = chain_file
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# Global configuration dictionary for OpenMC settings.
|
||||
config = _default_config()
|
||||
|
|
|
|||
|
|
@ -3,58 +3,101 @@ import os
|
|||
from pathlib import Path
|
||||
|
||||
import openmc
|
||||
from openmc.config import _default_config
|
||||
from openmc.data import decay
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope='module')
|
||||
def reset_config():
|
||||
config = dict(openmc.config)
|
||||
@pytest.fixture(autouse=True, scope='function')
|
||||
def reset_config_and_env():
|
||||
"""A fixture to ensure each test has a clean config, env, and CWD."""
|
||||
original_env = dict(os.environ)
|
||||
original_cwd = os.getcwd()
|
||||
|
||||
# Reset environment variables that affect config
|
||||
for key in ['OPENMC_CROSS_SECTIONS', 'OPENMC_MG_CROSS_SECTIONS', 'OPENMC_CHAIN_FILE']:
|
||||
if key in os.environ:
|
||||
del os.environ[key]
|
||||
|
||||
# Re-initialize the global config object
|
||||
openmc.config = _default_config()
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
openmc.config.clear()
|
||||
openmc.config.update(config)
|
||||
# Restore environment and CWD
|
||||
os.environ.clear()
|
||||
os.environ.update(original_env)
|
||||
os.chdir(original_cwd)
|
||||
|
||||
# Restore config one last time for safety between modules
|
||||
openmc.config = _default_config()
|
||||
|
||||
|
||||
def test_config_basics():
|
||||
assert isinstance(openmc.config, Mapping)
|
||||
for key, value in openmc.config.items():
|
||||
assert isinstance(key, str)
|
||||
if key == 'resolve_paths':
|
||||
assert isinstance(value, bool)
|
||||
else:
|
||||
assert isinstance(value, os.PathLike)
|
||||
|
||||
# Set and delete
|
||||
openmc.config['cross_sections'] = '/path/to/cross_sections.xml'
|
||||
with pytest.warns(UserWarning):
|
||||
openmc.config['cross_sections'] = '/path/to/cross_sections.xml'
|
||||
del openmc.config['cross_sections']
|
||||
assert 'cross_sections' not in openmc.config
|
||||
assert 'OPENMC_CROSS_SECTIONS' not in os.environ
|
||||
|
||||
# Can't use any key
|
||||
with pytest.raises(KeyError):
|
||||
openmc.config['🐖'] = '/like/to/eat/bacon'
|
||||
|
||||
# ensure relative paths are expanded into absolute
|
||||
# paths
|
||||
chain_path = Path('./chain.xml')
|
||||
openmc.config['chain_file'] = chain_path
|
||||
assert openmc.config['chain_file'] == chain_path.resolve()
|
||||
with pytest.raises(KeyError, match="Unrecognized config key: nuke"):
|
||||
openmc.config['nuke'] = '/like/to/eat/bacon'
|
||||
with pytest.raises(TypeError):
|
||||
openmc.config['resolve_paths'] = 'not a bool'
|
||||
|
||||
|
||||
def test_config_patch():
|
||||
openmc.config['cross_sections'] = '/path/to/cross_sections.xml'
|
||||
with openmc.config.patch('cross_sections', '/path/to/other.xml'):
|
||||
assert str(openmc.config['cross_sections']) == '/path/to/other.xml'
|
||||
assert str(openmc.config['cross_sections']) == '/path/to/cross_sections.xml'
|
||||
def test_config_path_resolution(tmp_path):
|
||||
"""Test path resolution logic."""
|
||||
os.chdir(tmp_path)
|
||||
relative_path = Path("some/file.xml")
|
||||
absolute_path = relative_path.resolve()
|
||||
|
||||
# Test with resolve_paths = True (default)
|
||||
with pytest.warns(UserWarning):
|
||||
openmc.config['cross_sections'] = relative_path
|
||||
assert openmc.config['cross_sections'] == absolute_path
|
||||
assert openmc.config['cross_sections'].is_absolute()
|
||||
|
||||
# Test with resolve_paths = False
|
||||
with openmc.config.patch('resolve_paths', False):
|
||||
with pytest.warns(UserWarning):
|
||||
openmc.config['chain_file'] = relative_path
|
||||
assert openmc.config['chain_file'] == relative_path
|
||||
assert not openmc.config['chain_file'].is_absolute()
|
||||
|
||||
assert openmc.config['resolve_paths'] is True
|
||||
|
||||
|
||||
def test_config_set_envvar():
|
||||
openmc.config['cross_sections'] = '/path/to/cross_sections.xml'
|
||||
assert os.environ['OPENMC_CROSS_SECTIONS'] == '/path/to/cross_sections.xml'
|
||||
def test_config_patch(tmp_path):
|
||||
file_a = tmp_path / "a.xml"; file_a.touch()
|
||||
file_b = tmp_path / "b.xml"; file_b.touch()
|
||||
openmc.config['cross_sections'] = file_a
|
||||
with openmc.config.patch('cross_sections', file_b):
|
||||
assert openmc.config['cross_sections'] == file_b.resolve()
|
||||
assert openmc.config['cross_sections'] == file_a.resolve()
|
||||
|
||||
openmc.config['mg_cross_sections'] = '/path/to/mg_cross_sections.h5'
|
||||
assert os.environ['OPENMC_MG_CROSS_SECTIONS'] == '/path/to/mg_cross_sections.h5'
|
||||
def test_config_set_envvar(tmp_path):
|
||||
"""Test that setting config also sets environment variables correctly."""
|
||||
os.chdir(tmp_path)
|
||||
relative_path = Path("relative.xml")
|
||||
with pytest.warns(UserWarning):
|
||||
openmc.config['cross_sections'] = relative_path
|
||||
expected_path = str(relative_path.resolve())
|
||||
assert os.environ['OPENMC_CROSS_SECTIONS'] == expected_path
|
||||
|
||||
openmc.config['chain_file'] = '/path/to/chain_file.xml'
|
||||
assert os.environ['OPENMC_CHAIN_FILE'] == '/path/to/chain_file.xml'
|
||||
|
||||
def test_config_warning_nonexistent_path(tmp_path):
|
||||
"""Test that a warning is issued for a path that does not exist."""
|
||||
bad_path = tmp_path / "a/path/that/does/not/exist.xml"
|
||||
with pytest.warns(UserWarning, match=f"Path '{bad_path}' does not exist."):
|
||||
openmc.config['chain_file'] = bad_path
|
||||
|
||||
|
||||
def test_config_chain_side_effect(tmp_path):
|
||||
"""Test that modifying chain_file clears decay data caches."""
|
||||
chain_file = tmp_path / "chain.xml"; chain_file.touch()
|
||||
decay._DECAY_ENERGY['U235'] = (1.0, 2.0)
|
||||
decay._DECAY_PHOTON_ENERGY['PU239'] = {}
|
||||
openmc.config['chain_file'] = chain_file
|
||||
assert not decay._DECAY_ENERGY and not decay._DECAY_PHOTON_ENERGY
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue