|
| 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 | +""" |
1 | 15 | from collections.abc import MutableMapping |
2 | 16 | from contextlib import contextmanager |
3 | 17 | import os |
4 | 18 | from pathlib import Path |
5 | 19 | import warnings |
| 20 | +from typing import Any, Dict, Iterator |
6 | 21 |
|
7 | 22 | from openmc.data import DataLibrary |
8 | 23 | from openmc.data.decay import _DECAY_ENERGY, _DECAY_PHOTON_ENERGY |
|
11 | 26 |
|
12 | 27 |
|
13 | 28 | 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} |
16 | 59 | self.update(data) |
17 | 60 |
|
18 | | - def __getitem__(self, key): |
| 61 | + def __getitem__(self, key: str) -> Any: |
19 | 62 | return self._mapping[key] |
20 | 63 |
|
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. |
31 | 70 |
|
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': |
44 | 80 | _DECAY_PHOTON_ENERGY.clear() |
45 | 81 | _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 | + |
46 | 112 | elif key == 'resolve_paths': |
| 113 | + if not isinstance(value, bool): |
| 114 | + raise TypeError("'resolve_paths' must be a boolean.") |
47 | 115 | self._mapping[key] = value |
48 | 116 | 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 | + ) |
52 | 122 |
|
53 | | - def __iter__(self): |
| 123 | + def __iter__(self) -> Iterator[str]: |
54 | 124 | return iter(self._mapping) |
55 | 125 |
|
56 | | - def __len__(self): |
| 126 | + def __len__(self) -> int: |
57 | 127 | return len(self._mapping) |
58 | 128 |
|
59 | | - def __repr__(self): |
| 129 | + def __repr__(self) -> str: |
60 | 130 | return repr(self._mapping) |
61 | 131 |
|
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] |
66 | 143 |
|
67 | 144 | @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. |
70 | 150 |
|
71 | 151 | Parameters |
72 | 152 | ---------- |
73 | 153 | 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 | +
|
77 | 169 | """ |
78 | 170 | previous_value = self.get(key) |
79 | 171 | 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 |
85 | 179 |
|
86 | | -def _default_config(): |
87 | | - """Return default configuration""" |
88 | | - config = _Config() |
89 | 180 |
|
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() |
91 | 196 | if "OPENMC_CROSS_SECTIONS" in os.environ: |
92 | 197 | config['cross_sections'] = os.environ["OPENMC_CROSS_SECTIONS"] |
93 | 198 | if "OPENMC_MG_CROSS_SECTIONS" in os.environ: |
94 | 199 | config['mg_cross_sections'] = os.environ["OPENMC_MG_CROSS_SECTIONS"] |
95 | | - |
96 | | - # Set depletion chain |
97 | 200 | 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 |
108 | 214 | if chain_file is not None: |
109 | 215 | config['chain_file'] = chain_file |
110 | | - |
111 | 216 | return config |
112 | 217 |
|
113 | 218 |
|
| 219 | +# Global configuration dictionary for OpenMC settings. |
114 | 220 | config = _default_config() |
0 commit comments