fmu-settings 0.3.2__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fmu-settings might be problematic. Click here for more details.

fmu/settings/_fmu_dir.py CHANGED
@@ -82,7 +82,9 @@ class FMUDirectoryBase:
82
82
  FileNotFoundError: If config file doesn't exist
83
83
  ValueError: If the updated config is invalid
84
84
  """
85
+ logger.info(f"Setting {key} in {self.path}")
85
86
  self.config.set(key, value)
87
+ logger.debug(f"Set {key} to {value}")
86
88
 
87
89
  def update_config(
88
90
  self: Self, updates: dict[str, Any]
@@ -147,6 +149,7 @@ class FMUDirectoryBase:
147
149
  relative_path: Path relative to the .fmu directory
148
150
  data: Bytes to write
149
151
  """
152
+ self._lock.ensure_can_write()
150
153
  file_path = self.get_file_path(relative_path)
151
154
  file_path.parent.mkdir(parents=True, exist_ok=True)
152
155
 
@@ -163,6 +166,7 @@ class FMUDirectoryBase:
163
166
  content: Text content to write
164
167
  encoding: Text encoding to use. Default utf-8
165
168
  """
169
+ self._lock.ensure_can_write()
166
170
  file_path = self.get_file_path(relative_path)
167
171
  file_path.parent.mkdir(parents=True, exist_ok=True)
168
172
 
@@ -0,0 +1,270 @@
1
+ """Functions related to finding and validating an existing global configuration."""
2
+
3
+ from pathlib import Path
4
+ from typing import Final
5
+
6
+ from pydantic import ValidationError
7
+
8
+ from fmu.config.utilities import yaml_load
9
+ from fmu.datamodels.fmu_results.global_configuration import GlobalConfiguration
10
+
11
+ from ._logging import null_logger
12
+
13
+ logger: Final = null_logger(__name__)
14
+
15
+
16
+ class InvalidGlobalConfigurationError(ValueError):
17
+ """Raised when a GlobalConfiguration contains invalid or disallowed content.
18
+
19
+ This includes Drogon test data or other disallowed masterdata.
20
+ This error is only raised when strict validation is enabled.
21
+ """
22
+
23
+
24
+ # These should all be normalized to lower case.
25
+ INVALID_NAMES: Final[tuple[str, ...]] = (
26
+ "drogon",
27
+ "drogon_2020",
28
+ "drogon_has_no_stratcolumn",
29
+ )
30
+ INVALID_UUIDS: Final[tuple[str, ...]] = (
31
+ "ad214d85-dac7-19da-e053-c918a4889309",
32
+ "ad214d85-8a1d-19da-e053-c918a4889310",
33
+ "00000000-0000-0000-0000-000000000000",
34
+ )
35
+ INVALID_STRAT_NAMES: Final[tuple[str, ...]] = (
36
+ "basevolantis",
37
+ "basetherys",
38
+ "basevalysar",
39
+ "basevolon",
40
+ "therys",
41
+ "toptherys",
42
+ "topvolantis",
43
+ "topvolon",
44
+ "topvalysar",
45
+ "valysar",
46
+ "volantis",
47
+ "volon",
48
+ )
49
+
50
+
51
+ def validate_global_configuration_strictly(cfg: GlobalConfiguration) -> None: # noqa: PLR0912
52
+ """Does stricter checks against a valid GlobalConfiguration file.
53
+
54
+ This is to prevent importing existing but incorrect data that should not make it
55
+ into a project .fmu configuration. An example of this is Drogon masterdata.
56
+
57
+ Args:
58
+ cfg: A GlobalConfiguration instance to be validated
59
+
60
+ Raises:
61
+ InvalidGlobalConfigurationError: If some value in the GlobalConfiguration
62
+ is invalid or not allowed
63
+ """
64
+ # Check model and access
65
+ if cfg.model.name.lower() in INVALID_NAMES:
66
+ raise InvalidGlobalConfigurationError(
67
+ f"Invalid name in 'model': {cfg.model.name}"
68
+ )
69
+ if cfg.access.asset.name.lower() in INVALID_NAMES:
70
+ raise InvalidGlobalConfigurationError(
71
+ f"Invalid name in 'access.asset': {cfg.access.asset.name}"
72
+ )
73
+
74
+ # Check masterdata
75
+
76
+ # smda.country
77
+ for country in cfg.masterdata.smda.country:
78
+ if str(country.uuid) in INVALID_UUIDS:
79
+ raise InvalidGlobalConfigurationError(
80
+ f"Invalid SMDA UUID in 'smda.country': {country.uuid}"
81
+ )
82
+
83
+ # smda.discovery
84
+ for discovery in cfg.masterdata.smda.discovery:
85
+ if discovery.short_identifier.lower() in INVALID_NAMES:
86
+ raise InvalidGlobalConfigurationError(
87
+ f"Invalid SMDA short identifier in 'smda.discovery': "
88
+ f"{discovery.short_identifier}"
89
+ )
90
+ if str(discovery.uuid) in INVALID_UUIDS:
91
+ raise InvalidGlobalConfigurationError(
92
+ f"Invalid SMDA UUID in 'smda.discovery': {discovery.uuid}"
93
+ )
94
+
95
+ # smda.field
96
+ for field in cfg.masterdata.smda.field:
97
+ if field.identifier.lower() in INVALID_NAMES:
98
+ raise InvalidGlobalConfigurationError(
99
+ f"Invalid SMDA identifier in 'smda.field': {field.identifier}"
100
+ )
101
+ if str(field.uuid) in INVALID_UUIDS:
102
+ raise InvalidGlobalConfigurationError(
103
+ f"Invalid SMDA UUID in 'smda.field': {field.uuid}"
104
+ )
105
+
106
+ # smda.coordinate_system
107
+ if (coord_uuid := str(cfg.masterdata.smda.coordinate_system.uuid)) in INVALID_UUIDS:
108
+ raise InvalidGlobalConfigurationError(
109
+ f"Invalid SMDA UUID in 'smda.coordinate_system': {coord_uuid}"
110
+ )
111
+
112
+ # smda.stratigraphic_column
113
+ strat = cfg.masterdata.smda.stratigraphic_column
114
+ if strat.identifier.lower() in INVALID_NAMES:
115
+ raise InvalidGlobalConfigurationError(
116
+ f"Invalid SMDA identifier in 'smda.stratigraphic_column': "
117
+ f"{strat.identifier}"
118
+ )
119
+ if str(strat.uuid) in INVALID_UUIDS:
120
+ raise InvalidGlobalConfigurationError(
121
+ f"Invalid SMDA UUID in 'smda.stratigraphic_column': {strat.uuid}"
122
+ )
123
+
124
+ # Check stratigraphy
125
+
126
+ if cfg.stratigraphy:
127
+ for key in cfg.stratigraphy:
128
+ if key.lower() in INVALID_STRAT_NAMES:
129
+ raise InvalidGlobalConfigurationError(
130
+ f"Invalid stratigraphy name in 'smda.stratigraphy': {key}"
131
+ )
132
+
133
+
134
+ def load_global_configuration_if_present(
135
+ path: Path, fmu_load: bool = False
136
+ ) -> GlobalConfiguration | None:
137
+ """Loads a global config/global variables at a path.
138
+
139
+ This loads via fmu-config, which is capable of loading a global _config_, which is
140
+ different from the global _variables_ in that it may still be in separate files
141
+ linked by the custom '!include' directive.
142
+
143
+ Args:
144
+ path: The path to the yaml file
145
+ fmu_load: Whether or not to load in the custom 'fmu' format. Default False.
146
+
147
+ Returns:
148
+ GlobalConfiguration instance or None.
149
+ """
150
+ loader = "fmu" if fmu_load else "standard"
151
+ try:
152
+ global_variables_dict = yaml_load(path, loader=loader)
153
+ global_config = GlobalConfiguration.model_validate(global_variables_dict)
154
+ logger.debug(f"Global variables at {path} has valid settings data")
155
+ except ValidationError as e:
156
+ logger.debug(f"Global variables at {path} failed validation: {e}")
157
+ return None
158
+ except Exception as e:
159
+ logger.debug(
160
+ f"Failed to load global variables at {path}: {type(e).__name__}: {e}"
161
+ )
162
+ return None
163
+ return global_config
164
+
165
+
166
+ def _find_global_variables_file(paths: list[Path]) -> GlobalConfiguration | None:
167
+ """Finds a valid global variables file, or not.
168
+
169
+ This is the _output_ file after fmuconfig is run.
170
+
171
+ Args:
172
+ paths: A list of Paths to check.
173
+
174
+ Returns:
175
+ A validated GlobalConfiguration or None.
176
+ """
177
+ for path in paths:
178
+ if not path.exists():
179
+ continue
180
+
181
+ global_variables_path = path
182
+ # If the path is a dir, and doesn't contain the right file, move on.
183
+ if path.is_dir():
184
+ global_variables_path = path / "global_variables.yml"
185
+ if not global_variables_path.exists():
186
+ continue
187
+
188
+ logger.info(f"Found global variables at {path}")
189
+ global_config = load_global_configuration_if_present(global_variables_path)
190
+ if not global_config:
191
+ continue
192
+ return global_config
193
+
194
+ return None
195
+
196
+
197
+ def _find_global_config_file(paths: list[Path]) -> GlobalConfiguration | None:
198
+ """Finds a valid global configuration file, or not.
199
+
200
+ This is the _input_ file, before fmuconfig is run.
201
+
202
+ Args:
203
+ paths: A list of Paths to check.
204
+
205
+ Returns:
206
+ A validated GlobalConfiguration or None.
207
+ """
208
+ for path in paths:
209
+ if not path.exists():
210
+ continue
211
+
212
+ logger.info(f"Found global config at {path}")
213
+ # May be global_config*.yml or global_master*.yml
214
+ for global_config_path in path.glob("**/global*.yml"):
215
+ global_config = load_global_configuration_if_present(
216
+ global_config_path, fmu_load=True
217
+ )
218
+ if not global_config:
219
+ continue
220
+ return global_config
221
+
222
+ return None
223
+
224
+
225
+ def find_global_config(
226
+ base_path: str | Path,
227
+ extra_output_paths: list[Path] | None = None,
228
+ extra_input_dirs: list[Path] | None = None,
229
+ strict: bool = True,
230
+ ) -> GlobalConfiguration | None:
231
+ """Try to locate a global configuration with valid masterdata in known location.
232
+
233
+ Extra paths may be provided
234
+
235
+ Args:
236
+ base_path: The path to the project root
237
+ extra_output_paths: A list of extra paths to a global _variables_.
238
+ extra_input_dirs: A list of extra dirs to a global _config_ might be.
239
+ strict: If True, valid data but invalid _content_ is disallowed, i.e. Drogon
240
+ data. Default True.
241
+
242
+ Returns:
243
+ A valid GlobalConfiguration instance, or None.
244
+ """
245
+ base_path = Path(base_path)
246
+
247
+ # Loads with 'fmu_load=False'
248
+ known_output_paths = [base_path / "fmuconfig/output/global_variables.yml"]
249
+ if extra_output_paths:
250
+ known_output_paths += extra_output_paths
251
+
252
+ global_config = _find_global_variables_file(known_output_paths)
253
+ if global_config:
254
+ if strict:
255
+ validate_global_configuration_strictly(global_config)
256
+ return global_config
257
+
258
+ # Loads with 'fmu_load=True'
259
+ known_input_paths = [base_path / "fmuconfig/input"]
260
+ if extra_input_dirs:
261
+ known_input_paths += extra_input_dirs
262
+
263
+ global_config = _find_global_config_file(known_input_paths)
264
+ if global_config:
265
+ if strict:
266
+ validate_global_configuration_strictly(global_config)
267
+ return global_config
268
+
269
+ logger.info("No global variables or config with valid settings data found.")
270
+ return None
fmu/settings/_init.py CHANGED
@@ -4,6 +4,8 @@ from pathlib import Path
4
4
  from textwrap import dedent
5
5
  from typing import Any, Final
6
6
 
7
+ from fmu.datamodels.fmu_results.global_configuration import GlobalConfiguration
8
+
7
9
  from ._fmu_dir import ProjectFMUDirectory, UserFMUDirectory
8
10
  from ._logging import null_logger
9
11
  from .models.project_config import ProjectConfig
@@ -66,7 +68,9 @@ def _create_fmu_directory(base_path: Path) -> None:
66
68
 
67
69
 
68
70
  def init_fmu_directory(
69
- base_path: str | Path, config_data: ProjectConfig | dict[str, Any] | None = None
71
+ base_path: str | Path,
72
+ config_data: ProjectConfig | dict[str, Any] | None = None,
73
+ global_config: GlobalConfiguration | None = None,
70
74
  ) -> ProjectFMUDirectory:
71
75
  """Creates and initializes a .fmu directory.
72
76
 
@@ -74,9 +78,11 @@ def init_fmu_directory(
74
78
  function.
75
79
 
76
80
  Args:
77
- base_path: Directory where .fmu should be created
81
+ base_path: Directory where .fmu should be created.
78
82
  config_data: Optional ProjectConfig instance or dictionary with configuration
79
- data
83
+ data.
84
+ global_config: Optional GlobaConfiguration instance with existing global config
85
+ data.
80
86
 
81
87
  Returns:
82
88
  Instance of FMUDirectory
@@ -98,12 +104,14 @@ def init_fmu_directory(
98
104
  fmu_dir.config.reset()
99
105
  if config_data:
100
106
  if isinstance(config_data, ProjectConfig):
101
- config_dict = config_data.model_dump()
102
- fmu_dir.update_config(config_dict)
103
- elif isinstance(config_data, dict):
104
- fmu_dir.update_config(config_data)
107
+ config_data = config_data.model_dump()
108
+ fmu_dir.update_config(config_data)
105
109
 
106
- logger.debug(f"Successfully initialized .fmu directory at '{fmu_dir}'")
110
+ if global_config:
111
+ for key, value in global_config.model_dump().items():
112
+ fmu_dir.set_config_value(key, value)
113
+
114
+ logger.info(f"Successfully initialized .fmu directory at '{fmu_dir}'")
107
115
  return fmu_dir
108
116
 
109
117
 
@@ -3,208 +3,47 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
- from typing import TYPE_CHECKING, Any, Final, Self, TypeVar
7
-
8
- from pydantic import ValidationError
6
+ from typing import TYPE_CHECKING, Final, Self
9
7
 
10
8
  from fmu.settings._logging import null_logger
11
9
  from fmu.settings.models.project_config import ProjectConfig
12
10
  from fmu.settings.models.user_config import UserConfig
13
- from fmu.settings.types import ResettableBaseModel # noqa: TC001
14
11
 
15
- from .pydantic_resource_manager import PydanticResourceManager
12
+ from .pydantic_resource_manager import (
13
+ MutablePydanticResourceManager,
14
+ )
16
15
 
17
16
  if TYPE_CHECKING:
18
17
  # Avoid circular dependency for type hint in __init__ only
19
18
  from fmu.settings._fmu_dir import (
20
- FMUDirectoryBase,
21
19
  ProjectFMUDirectory,
22
20
  UserFMUDirectory,
23
21
  )
24
22
 
25
23
  logger: Final = null_logger(__name__)
26
24
 
27
- T = TypeVar("T", bound=ResettableBaseModel)
28
-
29
25
 
30
- class ConfigManager(PydanticResourceManager[T]):
31
- """Manages the .fmu configuration file."""
26
+ class ProjectConfigManager(MutablePydanticResourceManager[ProjectConfig]):
27
+ """Manages the .fmu configuration file in a project."""
32
28
 
33
- def __init__(self: Self, fmu_dir: FMUDirectoryBase, config: type[T]) -> None:
34
- """Initializes the Config resource manager."""
35
- super().__init__(fmu_dir, config)
29
+ def __init__(self: Self, fmu_dir: ProjectFMUDirectory) -> None:
30
+ """Initializes the ProjectConfig resource manager."""
31
+ super().__init__(fmu_dir, ProjectConfig)
36
32
 
37
33
  @property
38
34
  def relative_path(self: Self) -> Path:
39
35
  """Returns the relative path to the config file."""
40
36
  return Path("config.json")
41
37
 
42
- def _get_dot_notation_key(
43
- self: Self, config_dict: dict[str, Any], key: str, default: Any = None
44
- ) -> Any:
45
- """Sets the value to a dot-notation key.
46
-
47
- Args:
48
- config_dict: The configuration dictionary we are modifying (by reference)
49
- key: The key to set
50
- default: Value to return if key is not found. Default None
51
-
52
- Returns:
53
- The value or default
54
- """
55
- parts = key.split(".")
56
- value = config_dict
57
- for part in parts:
58
- if isinstance(value, dict) and part in value:
59
- value = value[part]
60
- else:
61
- return default
62
-
63
- return value
64
-
65
- def get(self: Self, key: str, default: Any = None) -> Any:
66
- """Gets a configuration value by key.
67
-
68
- Supports dot notation for nested values (e.g., "foo.bar")
69
-
70
- Args:
71
- key: The configuration key
72
- default: Value to return if key is not found. Default None
73
-
74
- Returns:
75
- The configuration value or default
76
- """
77
- try:
78
- config = self.load()
79
-
80
- if "." in key:
81
- return self._get_dot_notation_key(config.model_dump(), key, default)
82
-
83
- if hasattr(config, key):
84
- return getattr(config, key)
85
-
86
- config_dict = config.model_dump()
87
- return config_dict.get(key, default)
88
- except FileNotFoundError as e:
89
- raise FileNotFoundError(
90
- f"Resource file for '{self.__class__.__name__}' not found "
91
- f"at: '{self.path}' when getting key {key}"
92
- ) from e
93
-
94
- def _set_dot_notation_key(
95
- self: Self, config_dict: dict[str, Any], key: str, value: Any
96
- ) -> None:
97
- """Sets the value to a dot-notation key.
98
-
99
- Args:
100
- config_dict: The configuration dictionary we are modifying (by reference)
101
- key: The key to set
102
- value: The value to set
103
- """
104
- parts = key.split(".")
105
- target = config_dict
106
-
107
- for part in parts[:-1]:
108
- if part not in target or not isinstance(target[part], dict):
109
- target[part] = {}
110
- target = target[part]
111
-
112
- target[parts[-1]] = value
113
-
114
- def set(self: Self, key: str, value: Any) -> None:
115
- """Sets a configuration value by key.
116
-
117
- Args:
118
- key: The configuration key
119
- value: The value to set
120
-
121
- Raises:
122
- FileNotFoundError: If config file doesn't exist
123
- ValueError: If the updated config is invalid
124
- """
125
- try:
126
- config = self.load()
127
- config_dict = config.model_dump()
128
-
129
- if "." in key:
130
- self._set_dot_notation_key(config_dict, key, value)
131
- else:
132
- config_dict[key] = value
133
38
 
134
- updated_config = config.model_validate(config_dict)
135
- self.save(updated_config)
136
- except ValidationError as e:
137
- raise ValueError(
138
- f"Invalid value set for '{self.__class__.__name__}' with "
139
- f"key '{key}', value '{value}': '{e}"
140
- ) from e
141
- except FileNotFoundError as e:
142
- raise FileNotFoundError(
143
- f"Resource file for '{self.__class__.__name__}' not found "
144
- f"at: '{self.path}' when setting key {key}"
145
- ) from e
146
-
147
- def update(self: Self, updates: dict[str, Any]) -> T:
148
- """Updates multiple configuration values at once.
149
-
150
- Args:
151
- updates: Dictionary of key-value pairs to update
152
-
153
- Returns:
154
- The updated Config object
155
-
156
- Raises:
157
- FileNotFoundError: If config file doesn't exist
158
- ValueError: If the updates config is invalid
159
- """
160
- try:
161
- config = self.load()
162
- config_dict = config.model_dump()
163
-
164
- flat_updates = {k: v for k, v in updates.items() if "." not in k}
165
- config_dict.update(flat_updates)
166
-
167
- for key, value in updates.items():
168
- if "." in key:
169
- self._set_dot_notation_key(config_dict, key, value)
170
-
171
- updated_config = config.model_validate(config_dict)
172
- self.save(updated_config)
173
- except ValidationError as e:
174
- raise ValueError(
175
- f"Invalid value set for '{self.__class__.__name__}' with "
176
- f"updates '{updates}': '{e}"
177
- ) from e
178
- except FileNotFoundError as e:
179
- raise FileNotFoundError(
180
- f"Resource file for '{self.__class__.__name__}' not found "
181
- f"at: '{self.path}' when setting updates {updates}"
182
- ) from e
183
-
184
- return updated_config
185
-
186
- def reset(self: Self) -> T:
187
- """Resets the configuration to defaults.
188
-
189
- Returns:
190
- The new default config object
191
- """
192
- config = self.model_class.reset()
193
- self.save(config)
194
- return config
195
-
196
-
197
- class ProjectConfigManager(ConfigManager[ProjectConfig]):
198
- """Manages the .fmu configuration file in a project."""
199
-
200
- def __init__(self: Self, fmu_dir: ProjectFMUDirectory) -> None:
201
- """Initializes the ProjectConfig resource manager."""
202
- super().__init__(fmu_dir, ProjectConfig)
203
-
204
-
205
- class UserConfigManager(ConfigManager[UserConfig]):
39
+ class UserConfigManager(MutablePydanticResourceManager[UserConfig]):
206
40
  """Manages the .fmu configuration file in a user's home directory."""
207
41
 
208
42
  def __init__(self: Self, fmu_dir: UserFMUDirectory) -> None:
209
43
  """Initializes the UserConfig resource manager."""
210
44
  super().__init__(fmu_dir, UserConfig)
45
+
46
+ @property
47
+ def relative_path(self: Self) -> Path:
48
+ """Returns the relative path to the config file."""
49
+ return Path("config.json")
@@ -168,6 +168,25 @@ class LockManager(PydanticResourceManager[LockInfo]):
168
168
  return False
169
169
  return self._is_mine(self._cache) and not self._is_stale()
170
170
 
171
+ def ensure_can_write(self: Self) -> None:
172
+ """Raise PermissionError if another process currently holds the lock."""
173
+ try:
174
+ lock_info = self.load(force=True, store_cache=False)
175
+ except Exception:
176
+ lock_info = None
177
+
178
+ if (
179
+ self.exists
180
+ and lock_info is not None
181
+ and not self.is_acquired()
182
+ and not self._is_stale(lock_info=lock_info)
183
+ ):
184
+ raise PermissionError(
185
+ "Cannot write to .fmu directory because it is locked by "
186
+ f"{lock_info.user}@{lock_info.hostname} (PID: {lock_info.pid}). "
187
+ f"Lock expires at {time.ctime(lock_info.expires_at)}."
188
+ )
189
+
171
190
  def refresh(self: Self) -> None:
172
191
  """Refresh/extend the lock expiration time.
173
192
 
@@ -239,9 +258,11 @@ class LockManager(PydanticResourceManager[LockInfo]):
239
258
  except Exception:
240
259
  return None
241
260
 
242
- def _is_stale(self: Self) -> bool:
261
+ def _is_stale(self: Self, lock_info: LockInfo | None = None) -> bool:
243
262
  """Check if existing lock is stale (expired or process dead)."""
244
- lock_info = self._safe_load()
263
+ if lock_info is None:
264
+ lock_info = self._safe_load()
265
+
245
266
  if not lock_info:
246
267
  return True
247
268
 
@@ -3,23 +3,28 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from typing import TYPE_CHECKING, Generic, Self, TypeVar
6
+ from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar
7
7
 
8
8
  from pydantic import BaseModel, ValidationError
9
9
 
10
+ from fmu.settings.types import ResettableBaseModel
11
+
10
12
  if TYPE_CHECKING:
13
+ # Avoid circular dependency for type hint in __init__ only
11
14
  from pathlib import Path
12
15
 
13
- # Avoid circular dependency for type hint in __init__ only
14
16
  from fmu.settings._fmu_dir import FMUDirectoryBase
15
17
 
16
- T = TypeVar("T", bound=BaseModel)
18
+ PydanticResource = TypeVar("PydanticResource", bound=BaseModel)
19
+ MutablePydanticResource = TypeVar("MutablePydanticResource", bound=ResettableBaseModel)
17
20
 
18
21
 
19
- class PydanticResourceManager(Generic[T]):
22
+ class PydanticResourceManager(Generic[PydanticResource]):
20
23
  """Base class for managing resources represented by Pydantic models."""
21
24
 
22
- def __init__(self: Self, fmu_dir: FMUDirectoryBase, model_class: type[T]) -> None:
25
+ def __init__(
26
+ self: Self, fmu_dir: FMUDirectoryBase, model_class: type[PydanticResource]
27
+ ) -> None:
23
28
  """Initializes the resource manager.
24
29
 
25
30
  Args:
@@ -28,7 +33,7 @@ class PydanticResourceManager(Generic[T]):
28
33
  """
29
34
  self.fmu_dir = fmu_dir
30
35
  self.model_class = model_class
31
- self._cache: T | None = None
36
+ self._cache: PydanticResource | None = None
32
37
 
33
38
  @property
34
39
  def relative_path(self: Self) -> Path:
@@ -48,7 +53,9 @@ class PydanticResourceManager(Generic[T]):
48
53
  """Returns whether or not the resource exists."""
49
54
  return self.path.exists()
50
55
 
51
- def load(self: Self, force: bool = False, store_cache: bool = True) -> T:
56
+ def load(
57
+ self: Self, force: bool = False, store_cache: bool = True
58
+ ) -> PydanticResource:
52
59
  """Loads the resource from disk and validates it as a Pydantic model.
53
60
 
54
61
  Args:
@@ -92,12 +99,177 @@ class PydanticResourceManager(Generic[T]):
92
99
 
93
100
  return self._cache
94
101
 
95
- def save(self: Self, model: T) -> None:
102
+ def save(self: Self, model: PydanticResource) -> None:
96
103
  """Save the Pydantic model to disk.
97
104
 
98
105
  Args:
99
106
  model: Validated Pydantic model instance
100
107
  """
108
+ self.fmu_dir._lock.ensure_can_write()
101
109
  json_data = model.model_dump_json(by_alias=True, indent=2)
102
110
  self.fmu_dir.write_text_file(self.relative_path, json_data)
103
111
  self._cache = model
112
+
113
+
114
+ class MutablePydanticResourceManager(PydanticResourceManager[MutablePydanticResource]):
115
+ """Manages the .fmu configuration file."""
116
+
117
+ def __init__(
118
+ self: Self, fmu_dir: FMUDirectoryBase, config: type[MutablePydanticResource]
119
+ ) -> None:
120
+ """Initializes the Config resource manager."""
121
+ super().__init__(fmu_dir, config)
122
+
123
+ def _get_dot_notation_key(
124
+ self: Self, config_dict: dict[str, Any], key: str, default: Any = None
125
+ ) -> Any:
126
+ """Sets the value to a dot-notation key.
127
+
128
+ Args:
129
+ config_dict: The configuration dictionary we are modifying (by reference)
130
+ key: The key to set
131
+ default: Value to return if key is not found. Default None
132
+
133
+ Returns:
134
+ The value or default
135
+ """
136
+ parts = key.split(".")
137
+ value = config_dict
138
+ for part in parts:
139
+ if isinstance(value, dict) and part in value:
140
+ value = value[part]
141
+ else:
142
+ return default
143
+
144
+ return value
145
+
146
+ def get(self: Self, key: str, default: Any = None) -> Any:
147
+ """Gets a configuration value by key.
148
+
149
+ Supports dot notation for nested values (e.g., "foo.bar")
150
+
151
+ Args:
152
+ key: The configuration key
153
+ default: Value to return if key is not found. Default None
154
+
155
+ Returns:
156
+ The configuration value or default
157
+ """
158
+ try:
159
+ config = self.load()
160
+
161
+ if "." in key:
162
+ return self._get_dot_notation_key(config.model_dump(), key, default)
163
+
164
+ if hasattr(config, key):
165
+ return getattr(config, key)
166
+
167
+ config_dict = config.model_dump()
168
+ return config_dict.get(key, default)
169
+ except FileNotFoundError as e:
170
+ raise FileNotFoundError(
171
+ f"Resource file for '{self.__class__.__name__}' not found "
172
+ f"at: '{self.path}' when getting key {key}"
173
+ ) from e
174
+
175
+ def _set_dot_notation_key(
176
+ self: Self, config_dict: dict[str, Any], key: str, value: Any
177
+ ) -> None:
178
+ """Sets the value to a dot-notation key.
179
+
180
+ Args:
181
+ config_dict: The configuration dictionary we are modifying (by reference)
182
+ key: The key to set
183
+ value: The value to set
184
+ """
185
+ parts = key.split(".")
186
+ target = config_dict
187
+
188
+ for part in parts[:-1]:
189
+ if part not in target or not isinstance(target[part], dict):
190
+ target[part] = {}
191
+ target = target[part]
192
+
193
+ target[parts[-1]] = value
194
+
195
+ def set(self: Self, key: str, value: Any) -> None:
196
+ """Sets a configuration value by key.
197
+
198
+ Args:
199
+ key: The configuration key
200
+ value: The value to set
201
+
202
+ Raises:
203
+ FileNotFoundError: If config file doesn't exist
204
+ ValueError: If the updated config is invalid
205
+ """
206
+ try:
207
+ config = self.load()
208
+ config_dict = config.model_dump()
209
+
210
+ if "." in key:
211
+ self._set_dot_notation_key(config_dict, key, value)
212
+ else:
213
+ config_dict[key] = value
214
+
215
+ updated_config = config.model_validate(config_dict)
216
+ self.save(updated_config)
217
+ except ValidationError as e:
218
+ raise ValueError(
219
+ f"Invalid value set for '{self.__class__.__name__}' with "
220
+ f"key '{key}', value '{value}': '{e}"
221
+ ) from e
222
+ except FileNotFoundError as e:
223
+ raise FileNotFoundError(
224
+ f"Resource file for '{self.__class__.__name__}' not found "
225
+ f"at: '{self.path}' when setting key {key}"
226
+ ) from e
227
+
228
+ def update(self: Self, updates: dict[str, Any]) -> MutablePydanticResource:
229
+ """Updates multiple configuration values at once.
230
+
231
+ Args:
232
+ updates: Dictionary of key-value pairs to update
233
+
234
+ Returns:
235
+ The updated Config object
236
+
237
+ Raises:
238
+ FileNotFoundError: If config file doesn't exist
239
+ ValueError: If the updates config is invalid
240
+ """
241
+ try:
242
+ config = self.load()
243
+ config_dict = config.model_dump()
244
+
245
+ flat_updates = {k: v for k, v in updates.items() if "." not in k}
246
+ config_dict.update(flat_updates)
247
+
248
+ for key, value in updates.items():
249
+ if "." in key:
250
+ self._set_dot_notation_key(config_dict, key, value)
251
+
252
+ updated_config = config.model_validate(config_dict)
253
+ self.save(updated_config)
254
+ except ValidationError as e:
255
+ raise ValueError(
256
+ f"Invalid value set for '{self.__class__.__name__}' with "
257
+ f"updates '{updates}': '{e}"
258
+ ) from e
259
+ except FileNotFoundError as e:
260
+ raise FileNotFoundError(
261
+ f"Resource file for '{self.__class__.__name__}' not found "
262
+ f"at: '{self.path}' when setting updates {updates}"
263
+ ) from e
264
+
265
+ return updated_config
266
+
267
+ def reset(self: Self) -> MutablePydanticResource:
268
+ """Resets the configuration to defaults.
269
+
270
+ Returns:
271
+ The new default config object
272
+ """
273
+ config = self.model_class.reset()
274
+ self.save(config)
275
+ return config
fmu/settings/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.3.2'
32
- __version_tuple__ = version_tuple = (0, 3, 2)
31
+ __version__ = version = '0.5.0'
32
+ __version_tuple__ = version_tuple = (0, 5, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fmu-settings
3
- Version: 0.3.2
3
+ Version: 0.5.0
4
4
  Summary: A library for managing FMU settings
5
5
  Author-email: Equinor <fg-fmu_atlas@equinor.com>
6
6
  License: GPL-3.0
@@ -17,7 +17,9 @@ Classifier: Natural Language :: English
17
17
  Requires-Python: >=3.11
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE
20
+ Requires-Dist: PyYAML
20
21
  Requires-Dist: annotated_types
22
+ Requires-Dist: fmu-config
21
23
  Requires-Dist: fmu-datamodels
22
24
  Requires-Dist: pydantic
23
25
  Provides-Extra: dev
@@ -27,6 +29,7 @@ Requires-Dist: pytest-cov; extra == "dev"
27
29
  Requires-Dist: pytest-mock; extra == "dev"
28
30
  Requires-Dist: pytest-xdist; extra == "dev"
29
31
  Requires-Dist: ruff; extra == "dev"
32
+ Requires-Dist: types-PyYAML; extra == "dev"
30
33
  Dynamic: license-file
31
34
 
32
35
  # fmu-settings
@@ -1,23 +1,24 @@
1
1
  fmu/__init__.py,sha256=htx6HlMme77I6pZ8U256-2B2cMJuELsu3JN3YM2Efh4,144
2
2
  fmu/settings/__init__.py,sha256=CkEE7al_uBCQO1lxBKN5LzyCwzzH5Aq6kkEIR7f-zTw,336
3
- fmu/settings/_fmu_dir.py,sha256=Br_hcfAXshiuDyWqG_qx5VXFpsCBJO1XDMPzdxxesoE,10684
4
- fmu/settings/_init.py,sha256=5CT7tV2XHz5wuLh97XozyLiKpwogrsfjpxm2dpn7KWE,4097
3
+ fmu/settings/_fmu_dir.py,sha256=XeZjec78q0IUOpBq-VMkKoWtzXwBeQi2qWRIh_SIFwU,10859
4
+ fmu/settings/_global_config.py,sha256=tYSQH_48cknaEeo8C_uraEaPXZrrN7cHYZOCX9G39yM,8916
5
+ fmu/settings/_init.py,sha256=ucueS0BlEsM3MkX7IaRISloH4vF7-_ZKSphrORbHgJ4,4381
5
6
  fmu/settings/_logging.py,sha256=nEdmZlNCBsB1GfDmFMKCjZmeuRp3CRlbz1EYUemc95Y,1104
6
- fmu/settings/_version.py,sha256=e8NqPtZ8fggRgk3GPrqZ_U_BDV8aSULw1u_Gn9NNbnk,704
7
+ fmu/settings/_version.py,sha256=fvHpBU3KZKRinkriKdtAt3crenOyysELF-M9y3ozg3U,704
7
8
  fmu/settings/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
9
  fmu/settings/types.py,sha256=aeXEsznBTT1YRRY_LSRqK1j2gmMmyLYYTGYl3a9fweU,513
9
10
  fmu/settings/_resources/__init__.py,sha256=LHYR_F7lNGdv8N6R3cEwds5CJQpkOthXFqsEs24vgF8,118
10
- fmu/settings/_resources/config_managers.py,sha256=IjOtS2lSU55GE_TWqHjbBPAzE8xQyVBvpHcfm0hTSnI,6822
11
- fmu/settings/_resources/lock_manager.py,sha256=_xzSJNF_qcpKpo8AxMfEgOhPxKXl3fZ2lRi0_y2eUEg,9206
12
- fmu/settings/_resources/pydantic_resource_manager.py,sha256=NV9qGnKaZ2RYt6o2NMaHvKyiZDIsxwD5y3y2a6QQtHw,3555
11
+ fmu/settings/_resources/config_managers.py,sha256=QcCLlSw8KdJKrkhGax5teFJzjgQG3ym7Ljs1DykjFbc,1570
12
+ fmu/settings/_resources/lock_manager.py,sha256=zdv1BZJlgB1BO9NepAdjY-YZ1-57HEJcTApE4UVS-8M,9995
13
+ fmu/settings/_resources/pydantic_resource_manager.py,sha256=AVvBUPnYOzmFYo9k5cA9QUme6ZOu0Q3IoLx_le7Mq20,9264
13
14
  fmu/settings/models/__init__.py,sha256=lRlXgl55ba2upmDzdvzx8N30JMq2Osnm8aa_xxTZn8A,112
14
15
  fmu/settings/models/_enums.py,sha256=SQUZ-2mQcTx4F0oefPFfuQzMKsKTSFSB-wq_CH7TBRE,734
15
16
  fmu/settings/models/_mappings.py,sha256=Z4Ex7MtmajBr6FjaNzmwDRwtJlaZZ8YKh9NDmZHRKPI,2832
16
17
  fmu/settings/models/lock_info.py,sha256=-oHDF9v9bDLCoFvEg4S6XXYLeo19zRAZ8HynCv75VWg,711
17
18
  fmu/settings/models/project_config.py,sha256=pxb54JmpXNMVAFUu_yJ89dNrYEk6hrPuFfFUpf84Jh0,1099
18
19
  fmu/settings/models/user_config.py,sha256=dWFTcZY6UnEgNTuGqB-izraJ657PecsW0e0Nt9GBDhI,2666
19
- fmu_settings-0.3.2.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
20
- fmu_settings-0.3.2.dist-info/METADATA,sha256=CC-jegtLPAS2vpUajvXTd3D_Fyow5q62Dwy4WDH4ypA,2024
21
- fmu_settings-0.3.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
22
- fmu_settings-0.3.2.dist-info/top_level.txt,sha256=Z-FIY3pxn0UK2Wxi9IJ7fKoLSraaxuNGi1eokiE0ShM,4
23
- fmu_settings-0.3.2.dist-info/RECORD,,
20
+ fmu_settings-0.5.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
21
+ fmu_settings-0.5.0.dist-info/METADATA,sha256=5Ff9Xfr_KsWUptwWt4HlKIkfnmME7D2UqclD-qzgrSo,2116
22
+ fmu_settings-0.5.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
23
+ fmu_settings-0.5.0.dist-info/top_level.txt,sha256=Z-FIY3pxn0UK2Wxi9IJ7fKoLSraaxuNGi1eokiE0ShM,4
24
+ fmu_settings-0.5.0.dist-info/RECORD,,