fmu-settings 0.5.4__tar.gz → 0.6.1__tar.gz

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.

Files changed (48) hide show
  1. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/PKG-INFO +1 -1
  2. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/settings/_fmu_dir.py +119 -19
  3. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/settings/_init.py +19 -32
  4. fmu_settings-0.6.1/src/fmu/settings/_readme_texts.py +34 -0
  5. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/settings/_resources/cache_manager.py +13 -9
  6. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/settings/_version.py +3 -3
  7. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/settings/models/project_config.py +2 -0
  8. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/settings/models/user_config.py +3 -0
  9. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu_settings.egg-info/PKG-INFO +1 -1
  10. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu_settings.egg-info/SOURCES.txt +1 -0
  11. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/tests/conftest.py +3 -0
  12. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/tests/test_fmu_dir.py +83 -2
  13. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/tests/test_init.py +3 -4
  14. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/tests/test_resources/test_cache_manager.py +9 -17
  15. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/tests/test_resources/test_lock_manager.py +18 -0
  16. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/tests/test_resources/test_resource_managers.py +6 -36
  17. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/.coveragerc +0 -0
  18. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/.github/pull_request_template.md +0 -0
  19. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/.github/workflows/ci.yml +0 -0
  20. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/.github/workflows/codeql.yml +0 -0
  21. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/.github/workflows/publish.yml +0 -0
  22. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/.gitignore +0 -0
  23. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/CONTRIBUTING.md +0 -0
  24. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/LICENSE +0 -0
  25. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/README.md +0 -0
  26. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/SECURITY.md +0 -0
  27. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/pyproject.toml +0 -0
  28. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/setup.cfg +0 -0
  29. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/__init__.py +0 -0
  30. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/settings/__init__.py +0 -0
  31. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/settings/_global_config.py +0 -0
  32. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/settings/_logging.py +0 -0
  33. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/settings/_resources/__init__.py +0 -0
  34. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/settings/_resources/config_managers.py +0 -0
  35. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/settings/_resources/lock_manager.py +0 -0
  36. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/settings/_resources/pydantic_resource_manager.py +0 -0
  37. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/settings/models/__init__.py +0 -0
  38. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/settings/models/_enums.py +0 -0
  39. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/settings/models/_mappings.py +0 -0
  40. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/settings/models/lock_info.py +0 -0
  41. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/settings/py.typed +0 -0
  42. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu/settings/types.py +0 -0
  43. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu_settings.egg-info/dependency_links.txt +0 -0
  44. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu_settings.egg-info/requires.txt +0 -0
  45. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/src/fmu_settings.egg-info/top_level.txt +0 -0
  46. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/tests/test_global_config.py +0 -0
  47. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/tests/test_resources/test_project_config.py +0 -0
  48. {fmu_settings-0.5.4 → fmu_settings-0.6.1}/tests/test_resources/test_user_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fmu-settings
3
- Version: 0.5.4
3
+ Version: 0.6.1
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
@@ -4,12 +4,13 @@ from pathlib import Path
4
4
  from typing import TYPE_CHECKING, Any, Final, Self, TypeAlias, cast
5
5
 
6
6
  from ._logging import null_logger
7
+ from ._readme_texts import PROJECT_README_CONTENT, USER_README_CONTENT
7
8
  from ._resources.cache_manager import CacheManager
8
9
  from ._resources.config_managers import (
9
10
  ProjectConfigManager,
10
11
  UserConfigManager,
11
12
  )
12
- from ._resources.lock_manager import LockManager
13
+ from ._resources.lock_manager import DEFAULT_LOCK_TIMEOUT, LockManager
13
14
  from .models.project_config import ProjectConfig
14
15
  from .models.user_config import UserConfig
15
16
 
@@ -24,13 +25,22 @@ class FMUDirectoryBase:
24
25
  config: FMUConfigManager
25
26
  _lock: LockManager
26
27
  _cache_manager: CacheManager
27
-
28
- def __init__(self: Self, base_path: str | Path) -> None:
28
+ _README_CONTENT: str = ""
29
+
30
+ def __init__(
31
+ self: Self,
32
+ base_path: str | Path,
33
+ cache_revisions: int = CacheManager.MIN_REVISIONS,
34
+ *,
35
+ lock_timeout_seconds: int = DEFAULT_LOCK_TIMEOUT,
36
+ ) -> None:
29
37
  """Initializes access to a .fmu directory.
30
38
 
31
39
  Args:
32
40
  base_path: The directory containing the .fmu directory or one of its parent
33
41
  dirs
42
+ cache_revisions: Number of revisions to retain in the cache. Minimum is 5.
43
+ lock_timeout_seconds: Lock expiration time in seconds. Default 20 minutes.
34
44
 
35
45
  Raises:
36
46
  FileExistsError: If .fmu exists but is not a directory
@@ -39,8 +49,8 @@ class FMUDirectoryBase:
39
49
  """
40
50
  self.base_path = Path(base_path).resolve()
41
51
  logger.debug(f"Initializing FMUDirectory from '{base_path}'")
42
- self._lock = LockManager(self)
43
- self._cache_manager = CacheManager(self, max_revisions=5)
52
+ self._lock = LockManager(self, timeout_seconds=lock_timeout_seconds)
53
+ self._cache_manager = CacheManager(self, max_revisions=cache_revisions)
44
54
 
45
55
  fmu_dir = self.base_path / ".fmu"
46
56
  if fmu_dir.exists():
@@ -72,8 +82,15 @@ class FMUDirectoryBase:
72
82
 
73
83
  @cache_max_revisions.setter
74
84
  def cache_max_revisions(self: Self, value: int) -> None:
75
- """Update the retention limit for revision snapshots."""
76
- self._cache_manager.max_revisions = value
85
+ """Update the retention limit for revision snapshots.
86
+
87
+ Args:
88
+ value: The new maximum number of revisions to retain. Minimum value is 5.
89
+ Values below 5 are set to 5.
90
+ """
91
+ clamped_value = max(CacheManager.MIN_REVISIONS, value)
92
+ self._cache_manager.max_revisions = clamped_value
93
+ self.set_config_value("cache_max_revisions", clamped_value)
77
94
 
78
95
  def get_config_value(self: Self, key: str, default: Any = None) -> Any:
79
96
  """Gets a configuration value by key.
@@ -230,15 +247,59 @@ class FMUDirectoryBase:
230
247
  """
231
248
  return self.get_file_path(relative_path).exists()
232
249
 
250
+ def restore(self: Self) -> None:
251
+ """Attempt to reconstruct missing .fmu files from in-memory state."""
252
+ if not self.path.exists():
253
+ self.path.mkdir(parents=True, exist_ok=True)
254
+ logger.info("Recreated missing .fmu directory at %s", self.path)
255
+
256
+ readme_path = self.get_file_path("README")
257
+ if self._README_CONTENT and not readme_path.exists():
258
+ self.write_text_file("README", self._README_CONTENT)
259
+ logger.info("Restored README at %s", readme_path)
260
+
261
+ config_path = self.config.path
262
+ if not config_path.exists():
263
+ cached_model = getattr(self.config, "_cache", None)
264
+ if cached_model is not None:
265
+ self.config.save(cached_model)
266
+ logger.info("Restored config.json from cached model at %s", config_path)
267
+ else:
268
+ self.config.reset()
269
+ logger.info("Restored config.json from defaults at %s", config_path)
270
+
233
271
 
234
272
  class ProjectFMUDirectory(FMUDirectoryBase):
235
273
  if TYPE_CHECKING:
236
274
  config: ProjectConfigManager
237
275
 
238
- def __init__(self, base_path: str | Path) -> None:
239
- """Initializes a project-based .fmu directory."""
276
+ _README_CONTENT: str = PROJECT_README_CONTENT
277
+
278
+ def __init__(
279
+ self: Self,
280
+ base_path: str | Path,
281
+ *,
282
+ lock_timeout_seconds: int = DEFAULT_LOCK_TIMEOUT,
283
+ ) -> None:
284
+ """Initializes a project-based .fmu directory.
285
+
286
+ Args:
287
+ base_path: Project directory containing the .fmu folder.
288
+ lock_timeout_seconds: Lock expiration time in seconds. Default 20 minutes.
289
+ """
240
290
  self.config = ProjectConfigManager(self)
241
- super().__init__(base_path)
291
+ super().__init__(
292
+ base_path,
293
+ CacheManager.MIN_REVISIONS,
294
+ lock_timeout_seconds=lock_timeout_seconds,
295
+ )
296
+ try:
297
+ max_revisions = self.config.get(
298
+ "cache_max_revisions", CacheManager.MIN_REVISIONS
299
+ )
300
+ self._cache_manager.max_revisions = max_revisions
301
+ except FileNotFoundError:
302
+ pass
242
303
 
243
304
  def update_config(self: Self, updates: dict[str, Any]) -> ProjectConfig:
244
305
  """Updates multiple configuration values at once.
@@ -286,11 +347,17 @@ class ProjectFMUDirectory(FMUDirectoryBase):
286
347
  return None
287
348
 
288
349
  @classmethod
289
- def find_nearest(cls: type[Self], start_path: str | Path = ".") -> Self:
350
+ def find_nearest(
351
+ cls: type[Self],
352
+ start_path: str | Path = ".",
353
+ *,
354
+ lock_timeout_seconds: int = DEFAULT_LOCK_TIMEOUT,
355
+ ) -> Self:
290
356
  """Factory method to find and open the nearest .fmu directory.
291
357
 
292
358
  Args:
293
359
  start_path: Path to start searching from. Default current working director
360
+ lock_timeout_seconds: Lock expiration time in seconds. Default 20 minutes.
294
361
 
295
362
  Returns:
296
363
  FMUDirectory instance
@@ -302,17 +369,38 @@ class ProjectFMUDirectory(FMUDirectoryBase):
302
369
  fmu_dir_path = cls.find_fmu_directory(start_path)
303
370
  if fmu_dir_path is None:
304
371
  raise FileNotFoundError(f"No .fmu directory found at or above {start_path}")
305
- return cls(fmu_dir_path.parent)
372
+ return cls(fmu_dir_path.parent, lock_timeout_seconds=lock_timeout_seconds)
306
373
 
307
374
 
308
375
  class UserFMUDirectory(FMUDirectoryBase):
309
376
  if TYPE_CHECKING:
310
377
  config: UserConfigManager
311
378
 
312
- def __init__(self) -> None:
313
- """Initializes a project-based .fmu directory."""
379
+ _README_CONTENT: str = USER_README_CONTENT
380
+
381
+ def __init__(
382
+ self: Self,
383
+ *,
384
+ lock_timeout_seconds: int = DEFAULT_LOCK_TIMEOUT,
385
+ ) -> None:
386
+ """Initializes a user .fmu directory.
387
+
388
+ Args:
389
+ lock_timeout_seconds: Lock expiration time in seconds. Default 20 minutes.
390
+ """
314
391
  self.config = UserConfigManager(self)
315
- super().__init__(Path.home())
392
+ super().__init__(
393
+ Path.home(),
394
+ CacheManager.MIN_REVISIONS,
395
+ lock_timeout_seconds=lock_timeout_seconds,
396
+ )
397
+ try:
398
+ max_revisions = self.config.get(
399
+ "cache_max_revisions", CacheManager.MIN_REVISIONS
400
+ )
401
+ self._cache_manager.max_revisions = max_revisions
402
+ except FileNotFoundError:
403
+ pass
316
404
 
317
405
  def update_config(self: Self, updates: dict[str, Any]) -> UserConfig:
318
406
  """Updates multiple configuration values at once.
@@ -330,12 +418,17 @@ class UserFMUDirectory(FMUDirectoryBase):
330
418
  return cast("UserConfig", super().update_config(updates))
331
419
 
332
420
 
333
- def get_fmu_directory(base_path: str | Path) -> ProjectFMUDirectory:
421
+ def get_fmu_directory(
422
+ base_path: str | Path,
423
+ *,
424
+ lock_timeout_seconds: int = DEFAULT_LOCK_TIMEOUT,
425
+ ) -> ProjectFMUDirectory:
334
426
  """Initializes access to a .fmu directory.
335
427
 
336
428
  Args:
337
429
  base_path: The directory containing the .fmu directory or one of its parent
338
430
  dirs
431
+ lock_timeout_seconds: Lock expiration time in seconds. Default 20 minutes.
339
432
 
340
433
  Returns:
341
434
  FMUDirectory instance
@@ -346,14 +439,19 @@ def get_fmu_directory(base_path: str | Path) -> ProjectFMUDirectory:
346
439
  PermissionError: If lacking permissions to read/write to the directory
347
440
 
348
441
  """
349
- return ProjectFMUDirectory(base_path)
442
+ return ProjectFMUDirectory(base_path, lock_timeout_seconds=lock_timeout_seconds)
350
443
 
351
444
 
352
- def find_nearest_fmu_directory(start_path: str | Path = ".") -> ProjectFMUDirectory:
445
+ def find_nearest_fmu_directory(
446
+ start_path: str | Path = ".",
447
+ *,
448
+ lock_timeout_seconds: int = DEFAULT_LOCK_TIMEOUT,
449
+ ) -> ProjectFMUDirectory:
353
450
  """Factory method to find and open the nearest .fmu directory.
354
451
 
355
452
  Args:
356
453
  start_path: Path to start searching from. Default current working directory
454
+ lock_timeout_seconds: Lock expiration time in seconds. Default 20 minutes.
357
455
 
358
456
  Returns:
359
457
  FMUDirectory instance
@@ -361,4 +459,6 @@ def find_nearest_fmu_directory(start_path: str | Path = ".") -> ProjectFMUDirect
361
459
  Raises:
362
460
  FileNotFoundError: If no .fmu directory is found
363
461
  """
364
- return ProjectFMUDirectory.find_nearest(start_path)
462
+ return ProjectFMUDirectory.find_nearest(
463
+ start_path, lock_timeout_seconds=lock_timeout_seconds
464
+ )
@@ -1,43 +1,18 @@
1
1
  """Initializes the .fmu directory."""
2
2
 
3
3
  from pathlib import Path
4
- from textwrap import dedent
5
4
  from typing import Any, Final
6
5
 
7
6
  from fmu.datamodels.fmu_results.global_configuration import GlobalConfiguration
8
7
 
9
8
  from ._fmu_dir import ProjectFMUDirectory, UserFMUDirectory
10
9
  from ._logging import null_logger
10
+ from ._readme_texts import PROJECT_README_CONTENT, USER_README_CONTENT
11
+ from ._resources.lock_manager import DEFAULT_LOCK_TIMEOUT
11
12
  from .models.project_config import ProjectConfig
12
13
 
13
14
  logger: Final = null_logger(__name__)
14
15
 
15
- _README = dedent("""\
16
- This directory contains static configuration data for your FMU project.
17
-
18
- You should *not* manually modify files within this directory. Doing so may
19
- result in erroneous behavior or erroneous data in your FMU project.
20
-
21
- Changes to data stored within this directory must happen through the FMU
22
- Settings application.
23
-
24
- Run `fmu-settings` to do this.
25
- """)
26
-
27
- _USER_README = dedent("""\
28
- This directory contains static data and configuration elements used by some
29
- components in FMU. It may also contains sensitive access tokens that should not be
30
- shared with others.
31
-
32
- You should *not* manually modify files within this directory. Doing so may
33
- result in erroneous behavior by some FMU components.
34
-
35
- Changes to data stored within this directory must happen through the FMU
36
- Settings application.
37
-
38
- Run `fmu-settings` to do this.
39
- """)
40
-
41
16
 
42
17
  def _create_fmu_directory(base_path: Path) -> None:
43
18
  """Creates the .fmu directory.
@@ -71,6 +46,8 @@ def init_fmu_directory(
71
46
  base_path: str | Path,
72
47
  config_data: ProjectConfig | dict[str, Any] | None = None,
73
48
  global_config: GlobalConfiguration | None = None,
49
+ *,
50
+ lock_timeout_seconds: int = DEFAULT_LOCK_TIMEOUT,
74
51
  ) -> ProjectFMUDirectory:
75
52
  """Creates and initializes a .fmu directory.
76
53
 
@@ -83,6 +60,7 @@ def init_fmu_directory(
83
60
  data.
84
61
  global_config: Optional GlobaConfiguration instance with existing global config
85
62
  data.
63
+ lock_timeout_seconds: Lock expiration time in seconds. Default 20 minutes.
86
64
 
87
65
  Returns:
88
66
  Instance of FMUDirectory
@@ -98,8 +76,11 @@ def init_fmu_directory(
98
76
 
99
77
  _create_fmu_directory(base_path)
100
78
 
101
- fmu_dir = ProjectFMUDirectory(base_path)
102
- fmu_dir.write_text_file("README", _README)
79
+ fmu_dir = ProjectFMUDirectory(
80
+ base_path,
81
+ lock_timeout_seconds=lock_timeout_seconds,
82
+ )
83
+ fmu_dir.write_text_file("README", PROJECT_README_CONTENT)
103
84
 
104
85
  fmu_dir.config.reset()
105
86
  if config_data:
@@ -115,9 +96,15 @@ def init_fmu_directory(
115
96
  return fmu_dir
116
97
 
117
98
 
118
- def init_user_fmu_directory() -> UserFMUDirectory:
99
+ def init_user_fmu_directory(
100
+ *,
101
+ lock_timeout_seconds: int = DEFAULT_LOCK_TIMEOUT,
102
+ ) -> UserFMUDirectory:
119
103
  """Creates and initializes a user's $HOME/.fmu directory.
120
104
 
105
+ Args:
106
+ lock_timeout_seconds: Lock expiration time in seconds. Default 20 minutes.
107
+
121
108
  Returns:
122
109
  Instance of FMUDirectory
123
110
 
@@ -131,8 +118,8 @@ def init_user_fmu_directory() -> UserFMUDirectory:
131
118
 
132
119
  _create_fmu_directory(Path.home())
133
120
 
134
- fmu_dir = UserFMUDirectory()
135
- fmu_dir.write_text_file("README", _USER_README)
121
+ fmu_dir = UserFMUDirectory(lock_timeout_seconds=lock_timeout_seconds)
122
+ fmu_dir.write_text_file("README", USER_README_CONTENT)
136
123
 
137
124
  fmu_dir.config.reset()
138
125
  logger.debug(f"Successfully initialized .fmu directory at '{fmu_dir}'")
@@ -0,0 +1,34 @@
1
+ """Shared README content for .fmu directories."""
2
+
3
+ from textwrap import dedent
4
+ from typing import Final
5
+
6
+ PROJECT_README_CONTENT: Final[str] = dedent(
7
+ """\
8
+ This directory contains static configuration data for your FMU project.
9
+
10
+ You should *not* manually modify files within this directory. Doing so may
11
+ result in erroneous behavior or erroneous data in your FMU project.
12
+
13
+ Changes to data stored within this directory must happen through the FMU
14
+ Settings application.
15
+
16
+ Run `fmu-settings` to do this.
17
+ """
18
+ )
19
+
20
+ USER_README_CONTENT: Final[str] = dedent(
21
+ """\
22
+ This directory contains static data and configuration elements used by some
23
+ components in FMU. It may also contains sensitive access tokens that should not be
24
+ shared with others.
25
+
26
+ You should *not* manually modify files within this directory. Doing so may
27
+ result in erroneous behavior by some FMU components.
28
+
29
+ Changes to data stored within this directory must happen through the FMU
30
+ Settings application.
31
+
32
+ Run `fmu-settings` to do this.
33
+ """
34
+ )
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from datetime import UTC, datetime
6
6
  from pathlib import Path
7
- from typing import TYPE_CHECKING, Final, Self
7
+ from typing import TYPE_CHECKING, ClassVar, Final, Self
8
8
  from uuid import uuid4
9
9
 
10
10
  from fmu.settings._logging import null_logger
@@ -25,6 +25,8 @@ _CACHEDIR_TAG_CONTENT: Final = (
25
25
  class CacheManager:
26
26
  """Stores complete file revisions under the `.fmu/cache` tree."""
27
27
 
28
+ MIN_REVISIONS: ClassVar[int] = 5
29
+
28
30
  def __init__(
29
31
  self: Self,
30
32
  fmu_dir: FMUDirectoryBase,
@@ -35,10 +37,11 @@ class CacheManager:
35
37
  Args:
36
38
  fmu_dir: The FMUDirectory instance.
37
39
  max_revisions: Maximum number of revisions to retain. Default is 5.
40
+ Values below 5 are set to 5.
38
41
  """
39
42
  self._fmu_dir = fmu_dir
40
43
  self._cache_root = Path("cache")
41
- self._max_revisions = max(0, max_revisions)
44
+ self._max_revisions = max(self.MIN_REVISIONS, max_revisions)
42
45
 
43
46
  @property
44
47
  def max_revisions(self: Self) -> int:
@@ -47,8 +50,13 @@ class CacheManager:
47
50
 
48
51
  @max_revisions.setter
49
52
  def max_revisions(self: Self, value: int) -> None:
50
- """Update the per-resource revision retention."""
51
- self._max_revisions = max(0, value)
53
+ """Update the per-resource revision retention.
54
+
55
+ Args:
56
+ value: The new maximum number of revisions. Minimum value is 5.
57
+ Values below 5 are set to 5.
58
+ """
59
+ self._max_revisions = max(self.MIN_REVISIONS, value)
52
60
 
53
61
  def store_revision(
54
62
  self: Self,
@@ -65,12 +73,8 @@ class CacheManager:
65
73
  encoding: Encoding used when persisting the snapshot. Defaults to UTF-8.
66
74
 
67
75
  Returns:
68
- Absolute filesystem path to the stored snapshot, or ``None`` if caching is
69
- disabled (``max_revisions`` equals zero).
76
+ Absolute filesystem path to the stored snapshot.
70
77
  """
71
- if self.max_revisions == 0:
72
- return None
73
-
74
78
  resource_file_path = Path(resource_file_path)
75
79
  cache_dir = self._ensure_resource_cache_dir(resource_file_path)
76
80
  snapshot_name = self._snapshot_filename(resource_file_path)
@@ -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.5.4'
32
- __version_tuple__ = version_tuple = (0, 5, 4)
31
+ __version__ = version = '0.6.1'
32
+ __version_tuple__ = version_tuple = (0, 6, 1)
33
33
 
34
- __commit_id__ = commit_id = 'g118a9d2da'
34
+ __commit_id__ = commit_id = 'gb2f5d6b1e'
@@ -23,6 +23,7 @@ class ProjectConfig(ResettableBaseModel):
23
23
  masterdata: Masterdata | None = Field(default=None)
24
24
  model: Model | None = Field(default=None)
25
25
  access: Access | None = Field(default=None)
26
+ cache_max_revisions: int = Field(default=5, ge=5)
26
27
 
27
28
  @classmethod
28
29
  def reset(cls: type[Self]) -> Self:
@@ -38,4 +39,5 @@ class ProjectConfig(ResettableBaseModel):
38
39
  masterdata=None,
39
40
  model=None,
40
41
  access=None,
42
+ cache_max_revisions=5,
41
43
  )
@@ -10,6 +10,7 @@ import annotated_types
10
10
  from pydantic import (
11
11
  AwareDatetime,
12
12
  BaseModel,
13
+ Field,
13
14
  SecretStr,
14
15
  field_serializer,
15
16
  field_validator,
@@ -42,6 +43,7 @@ class UserConfig(ResettableBaseModel):
42
43
 
43
44
  version: VersionStr
44
45
  created_at: AwareDatetime
46
+ cache_max_revisions: int = Field(default=5, ge=5)
45
47
  user_api_keys: UserAPIKeys
46
48
  recent_project_directories: RecentProjectDirectories
47
49
 
@@ -51,6 +53,7 @@ class UserConfig(ResettableBaseModel):
51
53
  return cls(
52
54
  version=__version__,
53
55
  created_at=datetime.now(UTC),
56
+ cache_max_revisions=5,
54
57
  user_api_keys=UserAPIKeys(),
55
58
  recent_project_directories=[],
56
59
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fmu-settings
3
- Version: 0.5.4
3
+ Version: 0.6.1
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
@@ -15,6 +15,7 @@ src/fmu/settings/_fmu_dir.py
15
15
  src/fmu/settings/_global_config.py
16
16
  src/fmu/settings/_init.py
17
17
  src/fmu/settings/_logging.py
18
+ src/fmu/settings/_readme_texts.py
18
19
  src/fmu/settings/_version.py
19
20
  src/fmu/settings/py.typed
20
21
  src/fmu/settings/types.py
@@ -39,6 +39,7 @@ def config_dict(unix_epoch_utc: datetime) -> dict[str, Any]:
39
39
  "version": __version__,
40
40
  "created_at": unix_epoch_utc,
41
41
  "created_by": "user",
42
+ "cache_max_revisions": 5,
42
43
  "masterdata": None,
43
44
  "model": None,
44
45
  "access": None,
@@ -283,6 +284,7 @@ def config_dict_with_masterdata(
283
284
  "version": __version__,
284
285
  "created_at": unix_epoch_utc,
285
286
  "created_by": "user",
287
+ "cache_max_revisions": 5,
286
288
  "masterdata": masterdata_dict,
287
289
  "model": model_dict,
288
290
  }
@@ -308,6 +310,7 @@ def user_config_dict(unix_epoch_utc: datetime) -> dict[str, Any]:
308
310
  return {
309
311
  "version": __version__,
310
312
  "created_at": unix_epoch_utc,
313
+ "cache_max_revisions": 5,
311
314
  "user_api_keys": {
312
315
  "smda_subscription": None,
313
316
  },
@@ -1,6 +1,8 @@
1
1
  """Tests for the ProjectFMUDirectory class."""
2
2
 
3
+ import inspect
3
4
  import json
5
+ import shutil
4
6
  from pathlib import Path
5
7
  from unittest.mock import patch
6
8
 
@@ -8,8 +10,13 @@ import pytest
8
10
  from pytest import MonkeyPatch
9
11
 
10
12
  from fmu.settings import __version__, find_nearest_fmu_directory, get_fmu_directory
11
- from fmu.settings._fmu_dir import ProjectFMUDirectory, UserFMUDirectory
12
- from fmu.settings._resources.lock_manager import LockManager
13
+ from fmu.settings._fmu_dir import (
14
+ FMUDirectoryBase,
15
+ ProjectFMUDirectory,
16
+ UserFMUDirectory,
17
+ )
18
+ from fmu.settings._readme_texts import PROJECT_README_CONTENT, USER_README_CONTENT
19
+ from fmu.settings._resources.lock_manager import DEFAULT_LOCK_TIMEOUT, LockManager
13
20
 
14
21
 
15
22
  def test_init_existing_directory(fmu_dir: ProjectFMUDirectory) -> None:
@@ -390,3 +397,77 @@ def test_acquire_lock_on_user_fmu(
390
397
  assert user_fmu_dir._lock.is_acquired()
391
398
  assert user_fmu_dir._lock.exists
392
399
  assert (user_fmu_dir.path / ".lock").exists()
400
+
401
+
402
+ def test_restore_rebuilds_project_fmu_from_cache(
403
+ fmu_dir: ProjectFMUDirectory,
404
+ ) -> None:
405
+ """Tests that restore should recreate missing files using cached config data."""
406
+ fmu_dir.update_config({"version": "123.4.5"})
407
+ cached_dump = json.loads((fmu_dir.path / "config.json").read_text())
408
+
409
+ shutil.rmtree(fmu_dir.path)
410
+ assert not fmu_dir.path.exists()
411
+
412
+ fmu_dir.restore()
413
+
414
+ assert fmu_dir.path.exists()
415
+ readme_path = fmu_dir.path / "README"
416
+ assert readme_path.exists()
417
+ assert readme_path.read_text() == PROJECT_README_CONTENT
418
+
419
+ restored_dump = json.loads((fmu_dir.path / "config.json").read_text())
420
+ assert restored_dump == cached_dump
421
+
422
+ cache_dir = fmu_dir.path / "cache" / "config"
423
+ assert cache_dir.is_dir()
424
+ assert any(cache_dir.iterdir())
425
+
426
+
427
+ def test_restore_resets_when_cache_missing(
428
+ fmu_dir: ProjectFMUDirectory,
429
+ ) -> None:
430
+ """Tests that restore should fall back to reset when no cached config exists."""
431
+ fmu_dir.config._cache = None
432
+ shutil.rmtree(fmu_dir.path)
433
+ assert not fmu_dir.path.exists()
434
+
435
+ with patch.object(
436
+ fmu_dir.config, "reset", wraps=fmu_dir.config.reset
437
+ ) as mock_reset:
438
+ fmu_dir.restore()
439
+
440
+ mock_reset.assert_called_once()
441
+ assert fmu_dir.path.exists()
442
+ readme_path = fmu_dir.path / "README"
443
+ assert readme_path.exists()
444
+ assert readme_path.read_text() == PROJECT_README_CONTENT
445
+ assert (fmu_dir.config.path).exists()
446
+
447
+
448
+ def test_restore_rebuilds_user_fmu(user_fmu_dir: UserFMUDirectory) -> None:
449
+ """Tests that user FMU restore should recreate missing files using cached state."""
450
+ cached_dump = json.loads((user_fmu_dir.path / "config.json").read_text())
451
+
452
+ shutil.rmtree(user_fmu_dir.path)
453
+ assert not user_fmu_dir.path.exists()
454
+
455
+ user_fmu_dir.restore()
456
+
457
+ assert user_fmu_dir.path.exists()
458
+ readme_path = user_fmu_dir.path / "README"
459
+ assert readme_path.exists()
460
+ assert readme_path.read_text() == USER_README_CONTENT
461
+
462
+ restored_dump = json.loads((user_fmu_dir.path / "config.json").read_text())
463
+ assert restored_dump == cached_dump
464
+
465
+
466
+ def test_fmu_directory_base_exposes_lock_timeout_kwarg() -> None:
467
+ """Tests that the kw-only lock timeout argument remains available."""
468
+ signature = inspect.signature(FMUDirectoryBase.__init__)
469
+ lock_timeout = signature.parameters.get("lock_timeout_seconds")
470
+
471
+ assert lock_timeout is not None, "lock_timeout_seconds kwarg missing from base init"
472
+ assert lock_timeout.kind is inspect.Parameter.KEYWORD_ONLY
473
+ assert lock_timeout.default == DEFAULT_LOCK_TIMEOUT
@@ -11,12 +11,11 @@ import pytest
11
11
  from fmu.settings import __version__
12
12
  from fmu.settings._global_config import find_global_config
13
13
  from fmu.settings._init import (
14
- _README,
15
- _USER_README,
16
14
  _create_fmu_directory,
17
15
  init_fmu_directory,
18
16
  init_user_fmu_directory,
19
17
  )
18
+ from fmu.settings._readme_texts import PROJECT_README_CONTENT, USER_README_CONTENT
20
19
  from fmu.settings.models.project_config import ProjectConfig
21
20
  from fmu.settings.models.user_config import UserConfig
22
21
 
@@ -128,7 +127,7 @@ def test_readme_is_written(tmp_path: Path, config_model: ProjectConfig) -> None:
128
127
 
129
128
  readme = fmu_dir.path / "README"
130
129
  assert readme.exists()
131
- assert readme.read_text() == _README
130
+ assert readme.read_text() == PROJECT_README_CONTENT
132
131
 
133
132
 
134
133
  def test_init_user_fmu_directory(
@@ -178,4 +177,4 @@ def test_user_readme_is_written(tmp_path: Path, config_model: ProjectConfig) ->
178
177
 
179
178
  readme = fmu_dir.path / "README"
180
179
  assert readme.exists()
181
- assert readme.read_text() == _USER_README
180
+ assert readme.read_text() == USER_README_CONTENT
@@ -5,7 +5,10 @@ from __future__ import annotations
5
5
  from pathlib import Path
6
6
  from typing import TYPE_CHECKING
7
7
 
8
- from fmu.settings._resources.cache_manager import _CACHEDIR_TAG_CONTENT, CacheManager
8
+ from fmu.settings._resources.cache_manager import (
9
+ _CACHEDIR_TAG_CONTENT,
10
+ CacheManager,
11
+ )
9
12
 
10
13
  if TYPE_CHECKING:
11
14
  import pytest
@@ -84,8 +87,9 @@ def test_cache_manager_trim_handles_missing_files(
84
87
  monkeypatch: pytest.MonkeyPatch,
85
88
  ) -> None:
86
89
  """Trimming gracefully handles concurrent removals."""
87
- manager = CacheManager(fmu_dir, max_revisions=1)
88
- manager.store_revision("foo.json", "first")
90
+ manager = CacheManager(fmu_dir, max_revisions=CacheManager.MIN_REVISIONS)
91
+ for i in range(CacheManager.MIN_REVISIONS + 2):
92
+ manager.store_revision("foo.json", f"content_{i}")
89
93
 
90
94
  original_unlink = Path.unlink
91
95
 
@@ -98,19 +102,7 @@ def test_cache_manager_trim_handles_missing_files(
98
102
 
99
103
  monkeypatch.setattr(Path, "unlink", flaky_unlink)
100
104
 
101
- manager.store_revision("foo.json", "second")
105
+ manager.store_revision("foo.json", "final")
102
106
 
103
107
  config_cache = fmu_dir.path / "cache" / "foo"
104
- assert getattr(flaky_unlink, "raised", False) is True
105
- assert len(_read_snapshot_names(config_cache)) == 1
106
-
107
-
108
- def test_cache_manager_max_revisions_zero_skips_storage(
109
- fmu_dir: ProjectFMUDirectory,
110
- ) -> None:
111
- """Storing with zero retention should return None and create nothing."""
112
- manager = CacheManager(fmu_dir, max_revisions=0)
113
- result = manager.store_revision("foo.json", "data")
114
- assert result is None
115
- cache_dir = fmu_dir.path / "cache" / "foo"
116
- assert not cache_dir.exists()
108
+ assert len(_read_snapshot_names(config_cache)) == CacheManager.MIN_REVISIONS
@@ -523,6 +523,24 @@ def test_refresh_without_lock_file(
523
523
  assert fmu_dir._lock.is_acquired() is False
524
524
 
525
525
 
526
+ def test_refresh_missing_lock_releases_owned_lock(
527
+ fmu_dir: ProjectFMUDirectory,
528
+ ) -> None:
529
+ """Tests refresh releases cached state when lock file is missing."""
530
+ lock = LockManager(fmu_dir)
531
+ lock.acquire()
532
+ lock.path.unlink()
533
+
534
+ with (
535
+ patch.object(lock, "is_acquired", return_value=True),
536
+ patch.object(lock, "release") as mock_release,
537
+ pytest.raises(LockNotFoundError, match="lock file does not exist"),
538
+ ):
539
+ lock.refresh()
540
+
541
+ mock_release.assert_called_once()
542
+
543
+
526
544
  def test_refresh_without_owning_lock(
527
545
  fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
528
546
  ) -> None:
@@ -10,7 +10,6 @@ import pytest
10
10
  from pydantic import BaseModel
11
11
 
12
12
  from fmu.settings._fmu_dir import ProjectFMUDirectory
13
- from fmu.settings._resources.cache_manager import CacheManager
14
13
  from fmu.settings._resources.lock_manager import LockManager
15
14
  from fmu.settings._resources.pydantic_resource_manager import PydanticResourceManager
16
15
 
@@ -230,57 +229,28 @@ def test_pydantic_resource_manager_save_stores_revision_when_enabled(
230
229
 
231
230
 
232
231
  def test_pydantic_resource_manager_revision_cache_trims_excess(
233
- fmu_dir: ProjectFMUDirectory, monkeypatch: pytest.MonkeyPatch
234
- ) -> None:
235
- """Revision caching should retain only the configured number of snapshots."""
236
- original_limit = fmu_dir.cache_max_revisions
237
- fmu_dir.cache_max_revisions = 2
238
- try:
239
- sequence = iter(["rev1.json", "rev2.json", "rev3.json"])
240
- monkeypatch.setattr(
241
- CacheManager,
242
- "_snapshot_filename",
243
- lambda self, config_file_path: next(sequence),
244
- )
245
-
246
- a = AManager(fmu_dir)
247
- a.save(A(foo="one"))
248
- a.save(A(foo="two"))
249
- a.save(A(foo="three"))
250
- finally:
251
- fmu_dir.cache_max_revisions = original_limit
252
-
253
- config_cache = fmu_dir.path / "cache" / "foo"
254
- snapshots = sorted(p.name for p in config_cache.iterdir())
255
- assert snapshots == ["rev2.json", "rev3.json"]
256
-
257
- assert (
258
- json.loads((config_cache / "rev3.json").read_text(encoding="utf-8"))["foo"]
259
- == "three"
260
- )
261
-
262
-
263
- def test_pydantic_resource_manager_respects_retention_setting(
264
232
  fmu_dir: ProjectFMUDirectory,
265
233
  ) -> None:
266
- """Saving uses the cache manager retention setting."""
234
+ """Revision caching should retain only the configured number of snapshots."""
267
235
  original_limit = fmu_dir.cache_max_revisions
268
- fmu_dir.cache_max_revisions = 3
236
+ fmu_dir.cache_max_revisions = 5
269
237
  try:
270
238
  a = AManager(fmu_dir)
271
239
  a.save(A(foo="one"))
272
240
  a.save(A(foo="two"))
273
241
  a.save(A(foo="three"))
274
242
  a.save(A(foo="four"))
243
+ a.save(A(foo="five"))
244
+ a.save(A(foo="six"))
275
245
  finally:
276
246
  fmu_dir.cache_max_revisions = original_limit
277
247
 
278
248
  config_cache = fmu_dir.path / "cache" / "foo"
279
249
  snapshots = sorted(p.name for p in config_cache.iterdir())
280
- assert len(snapshots) == 3 # noqa: PLR2004
250
+ assert len(snapshots) == 5 # noqa: PLR2004
281
251
 
282
252
  contents = [
283
253
  json.loads((config_cache / name).read_text(encoding="utf-8"))["foo"]
284
254
  for name in snapshots
285
255
  ]
286
- assert contents == ["two", "three", "four"]
256
+ assert contents == ["two", "three", "four", "five", "six"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes