Skip to content

Commit 25d64c9

Browse files
Refactor and Harden Configuration Management (#3461)
Co-authored-by: Paul Romano <paul.k.romano@gmail.com>
1 parent c116288 commit 25d64c9

2 files changed

Lines changed: 245 additions & 96 deletions

File tree

openmc/config.py

Lines changed: 168 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
1+
"""Module for handling global configuration in OpenMC.
2+
3+
This module exports a single object, `config`, that can be used to control
4+
various settings, primarily paths to data files. It acts like a dictionary but
5+
with special behaviors.
6+
7+
Examples
8+
--------
9+
>>> import openmc
10+
>>> openmc.config['cross_sections'] = '/path/to/my/cross_sections.xml'
11+
>>> print(openmc.config)
12+
{'resolve_paths': True, 'cross_sections': PosixPath('/path/to/my/cross_sections.xml')}
13+
14+
"""
115
from collections.abc import MutableMapping
216
from contextlib import contextmanager
317
import os
418
from pathlib import Path
519
import warnings
20+
from typing import Any, Dict, Iterator
621

722
from openmc.data import DataLibrary
823
from openmc.data.decay import _DECAY_ENERGY, _DECAY_PHOTON_ENERGY
@@ -11,104 +26,195 @@
1126

1227

1328
class _Config(MutableMapping):
14-
def __init__(self, data=()):
15-
self._mapping = {'resolve_paths': True}
29+
"""A configuration dictionary for OpenMC with special handling for path-like values.
30+
31+
This class enforces valid configuration keys and synchronizes path-related
32+
settings with their corresponding environment variables.
33+
34+
Attributes
35+
----------
36+
cross_sections : pathlib.Path
37+
Path to a cross_sections.xml file. Also sets/unsets the
38+
OPENMC_CROSS_SECTIONS environment variable.
39+
mg_cross_sections : pathlib.Path
40+
Path to a multi-group cross_sections.h5 file. Also sets/unsets
41+
the OPENMC_MG_CROSS_SECTIONS environment variable.
42+
chain_file : pathlib.Path
43+
Path to a depletion chain XML file. Also sets/unsets the
44+
OPENMC_CHAIN_FILE environment variable. Setting or deleting this
45+
clears internal decay data caches.
46+
resolve_paths : bool
47+
If True (default), all paths assigned are resolved to absolute
48+
paths. If False, paths are stored as they are provided.
49+
50+
"""
51+
_PATH_KEYS: Dict[str, str] = {
52+
'cross_sections': 'OPENMC_CROSS_SECTIONS',
53+
'mg_cross_sections': 'OPENMC_MG_CROSS_SECTIONS',
54+
'chain_file': 'OPENMC_CHAIN_FILE'
55+
}
56+
57+
def __init__(self, data: dict = ()):
58+
self._mapping: Dict[str, Any] = {'resolve_paths': True}
1659
self.update(data)
1760

18-
def __getitem__(self, key):
61+
def __getitem__(self, key: str) -> Any:
1962
return self._mapping[key]
2063

21-
def __delitem__(self, key):
22-
del self._mapping[key]
23-
if key == 'cross_sections':
24-
del os.environ['OPENMC_CROSS_SECTIONS']
25-
elif key == 'mg_cross_sections':
26-
del os.environ['OPENMC_MG_CROSS_SECTIONS']
27-
elif key == 'chain_file':
28-
del os.environ['OPENMC_CHAIN_FILE']
29-
# Reset photon source data since it relies on chain file
30-
_DECAY_PHOTON_ENERGY.clear()
64+
def __delitem__(self, key: str):
65+
"""Delete a configuration key.
66+
67+
This also deletes the corresponding environment variable if the key is a
68+
path-like key, and clears decay data caches if 'chain_file' is deleted.
69+
'resolve_paths' cannot be deleted.
3170
32-
def __setitem__(self, key, value):
33-
if key == 'cross_sections':
34-
# Force environment variable to match
35-
self._set_path(key, value)
36-
os.environ['OPENMC_CROSS_SECTIONS'] = str(value)
37-
elif key == 'mg_cross_sections':
38-
self._set_path(key, value)
39-
os.environ['OPENMC_MG_CROSS_SECTIONS'] = str(value)
40-
elif key == 'chain_file':
41-
self._set_path(key, value)
42-
os.environ['OPENMC_CHAIN_FILE'] = str(value)
43-
# Reset photon source data since it relies on chain file
71+
"""
72+
if key == 'resolve_paths':
73+
raise KeyError("'resolve_paths' cannot be deleted.")
74+
del self._mapping[key]
75+
if key in self._PATH_KEYS:
76+
env_var = self._PATH_KEYS[key]
77+
if env_var in os.environ:
78+
del os.environ[env_var]
79+
if key == 'chain_file':
4480
_DECAY_PHOTON_ENERGY.clear()
4581
_DECAY_ENERGY.clear()
82+
83+
def __setitem__(self, key: str, value: Any):
84+
"""Set a configuration key and its corresponding value.
85+
86+
For path-like keys, this method performs several actions:
87+
1. Resolves the path to an absolute path if `resolve_paths` is True.
88+
2. Stores the `pathlib.Path` object.
89+
3. Sets the corresponding environment variable (e.g., OPENMC_CROSS_SECTIONS).
90+
4. For 'chain_file', clears internal decay data caches.
91+
5. Issues a `UserWarning` if the final path does not exist.
92+
93+
"""
94+
if key in self._PATH_KEYS:
95+
p = Path(value)
96+
# Use .get() for robustness, defaulting to True
97+
if self._mapping.get('resolve_paths', True):
98+
stored_path = p.resolve(strict=False)
99+
else:
100+
stored_path = p
101+
102+
self._mapping[key] = stored_path
103+
os.environ[self._PATH_KEYS[key]] = str(stored_path)
104+
105+
if key == 'chain_file':
106+
_DECAY_PHOTON_ENERGY.clear()
107+
_DECAY_ENERGY.clear()
108+
109+
if not stored_path.exists():
110+
warnings.warn(f"Path '{stored_path}' does not exist.", UserWarning)
111+
46112
elif key == 'resolve_paths':
113+
if not isinstance(value, bool):
114+
raise TypeError("'resolve_paths' must be a boolean.")
47115
self._mapping[key] = value
48116
else:
49-
raise KeyError(f'Unrecognized config key: {key}. Acceptable keys '
50-
'are "cross_sections", "mg_cross_sections", '
51-
'"chain_file", and "resolve_paths".')
117+
valid_keys = list(self._PATH_KEYS.keys()) + ['resolve_paths']
118+
raise KeyError(
119+
f"Unrecognized config key: {key}. Acceptable keys are: "
120+
f"{', '.join(repr(k) for k in valid_keys)}."
121+
)
52122

53-
def __iter__(self):
123+
def __iter__(self) -> Iterator[str]:
54124
return iter(self._mapping)
55125

56-
def __len__(self):
126+
def __len__(self) -> int:
57127
return len(self._mapping)
58128

59-
def __repr__(self):
129+
def __repr__(self) -> str:
60130
return repr(self._mapping)
61131

62-
def _set_path(self, key, value):
63-
self._mapping[key] = p = Path(value).resolve()
64-
if not p.exists():
65-
warnings.warn(f"'{value}' does not exist.")
132+
def clear(self):
133+
"""Clear all configuration keys except for 'resolve_paths'.
134+
135+
This ensures that the path resolution behavior is not accidentally reset
136+
when clearing the configuration.
137+
138+
"""
139+
# Create a copy of keys to iterate over for safe deletion
140+
keys_to_delete = [k for k in self._mapping if k != 'resolve_paths']
141+
for key in keys_to_delete:
142+
del self[key]
66143

67144
@contextmanager
68-
def patch(self, key, value):
69-
"""Temporarily change a value in the configuration.
145+
def patch(self, key: str, value: Any):
146+
"""Context manager to temporarily change a configuration value.
147+
148+
After the `with` block, the configuration is restored to its original
149+
state.
70150
71151
Parameters
72152
----------
73153
key : str
74-
Key to change
75-
value : object
76-
New value
154+
The key of the configuration value to change.
155+
value
156+
The new temporary value.
157+
158+
Examples
159+
--------
160+
>>> openmc.config['cross_sections'] = 'endf71.xml'
161+
>>> with openmc.config.patch('cross_sections', 'fendl32.xml'):
162+
... # Code in this block sees the new value
163+
... print(f"Inside with block: {openmc.config['cross_sections']}")
164+
>>> # Outside the block, the value is restored
165+
>>> print(f"Outside with block: {openmc.config['cross_sections']}")
166+
Inside with block: fendl32.xml
167+
Outside with block: endf71.xml
168+
77169
"""
78170
previous_value = self.get(key)
79171
self[key] = value
80-
yield
81-
if previous_value is None:
82-
del self[key]
83-
else:
84-
self[key] = previous_value
172+
try:
173+
yield
174+
finally:
175+
if previous_value is None:
176+
del self[key]
177+
else:
178+
self[key] = previous_value
85179

86-
def _default_config():
87-
"""Return default configuration"""
88-
config = _Config()
89180

90-
# Set cross sections using environment variable
181+
def _default_config() -> _Config:
182+
"""Create a configuration initialized from environment variables.
183+
184+
This function checks for OPENMC_CROSS_SECTIONS, OPENMC_MG_CROSS_SECTIONS,
185+
and OPENMC_CHAIN_FILE environment variables. It also has logic to find
186+
a chain file within a `cross_sections.xml` file if one is not
187+
explicitly set.
188+
189+
Returns
190+
-------
191+
_Config
192+
A new configuration object.
193+
194+
"""
195+
config = _Config()
91196
if "OPENMC_CROSS_SECTIONS" in os.environ:
92197
config['cross_sections'] = os.environ["OPENMC_CROSS_SECTIONS"]
93198
if "OPENMC_MG_CROSS_SECTIONS" in os.environ:
94199
config['mg_cross_sections'] = os.environ["OPENMC_MG_CROSS_SECTIONS"]
95-
96-
# Set depletion chain
97200
chain_file = os.environ.get("OPENMC_CHAIN_FILE")
98-
if (chain_file is None and
99-
config.get('cross_sections') is not None and
100-
config['cross_sections'].exists()
101-
):
102-
# Check for depletion chain in cross_sections.xml
103-
data = DataLibrary.from_xml(config['cross_sections'])
104-
for lib in reversed(data.libraries):
105-
if lib['type'] == 'depletion_chain':
106-
chain_file = lib['path']
107-
break
201+
xs_path = config.get('cross_sections')
202+
if chain_file is None and xs_path is not None and xs_path.exists():
203+
try:
204+
data = DataLibrary.from_xml(xs_path)
205+
except Exception:
206+
# Let this pass silently if cross_sections.xml can't be parsed
207+
# or if a dependency like lxml is not available.
208+
pass
209+
else:
210+
for lib in reversed(data.libraries):
211+
if lib['type'] == 'depletion_chain':
212+
chain_file = xs_path.parent / lib['path']
213+
break
108214
if chain_file is not None:
109215
config['chain_file'] = chain_file
110-
111216
return config
112217

113218

219+
# Global configuration dictionary for OpenMC settings.
114220
config = _default_config()

0 commit comments

Comments
 (0)