fmu-settings 0.5.3__tar.gz → 0.6.0__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.3 → fmu_settings-0.6.0}/PKG-INFO +1 -1
  2. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/_fmu_dir.py +115 -13
  3. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/_init.py +19 -32
  4. fmu_settings-0.6.0/src/fmu/settings/_readme_texts.py +34 -0
  5. fmu_settings-0.6.0/src/fmu/settings/_resources/cache_manager.py +153 -0
  6. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/_resources/lock_manager.py +28 -16
  7. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/_resources/pydantic_resource_manager.py +11 -2
  8. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/_version.py +3 -3
  9. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/models/project_config.py +2 -0
  10. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/models/user_config.py +3 -0
  11. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu_settings.egg-info/PKG-INFO +1 -1
  12. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu_settings.egg-info/SOURCES.txt +3 -0
  13. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/tests/conftest.py +3 -0
  14. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/tests/test_fmu_dir.py +102 -2
  15. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/tests/test_global_config.py +20 -20
  16. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/tests/test_init.py +3 -4
  17. fmu_settings-0.6.0/tests/test_resources/test_cache_manager.py +108 -0
  18. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/tests/test_resources/test_lock_manager.py +35 -5
  19. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/tests/test_resources/test_resource_managers.py +70 -0
  20. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/.coveragerc +0 -0
  21. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/.github/pull_request_template.md +0 -0
  22. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/.github/workflows/ci.yml +0 -0
  23. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/.github/workflows/codeql.yml +0 -0
  24. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/.github/workflows/publish.yml +0 -0
  25. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/.gitignore +0 -0
  26. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/CONTRIBUTING.md +0 -0
  27. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/LICENSE +0 -0
  28. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/README.md +0 -0
  29. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/SECURITY.md +0 -0
  30. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/pyproject.toml +0 -0
  31. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/setup.cfg +0 -0
  32. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/__init__.py +0 -0
  33. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/__init__.py +0 -0
  34. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/_global_config.py +0 -0
  35. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/_logging.py +0 -0
  36. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/_resources/__init__.py +0 -0
  37. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/_resources/config_managers.py +0 -0
  38. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/models/__init__.py +0 -0
  39. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/models/_enums.py +0 -0
  40. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/models/_mappings.py +0 -0
  41. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/models/lock_info.py +0 -0
  42. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/py.typed +0 -0
  43. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/types.py +0 -0
  44. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu_settings.egg-info/dependency_links.txt +0 -0
  45. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu_settings.egg-info/requires.txt +0 -0
  46. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu_settings.egg-info/top_level.txt +0 -0
  47. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/tests/test_resources/test_project_config.py +0 -0
  48. {fmu_settings-0.5.3 → fmu_settings-0.6.0}/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.3
3
+ Version: 0.6.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
@@ -1,14 +1,16 @@
1
1
  """Main interface for working with .fmu directory."""
2
2
 
3
3
  from pathlib import Path
4
- from typing import Any, Final, Self, TypeAlias, cast
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
8
+ from ._resources.cache_manager import CacheManager
7
9
  from ._resources.config_managers import (
8
10
  ProjectConfigManager,
9
11
  UserConfigManager,
10
12
  )
11
- from ._resources.lock_manager import LockManager
13
+ from ._resources.lock_manager import DEFAULT_LOCK_TIMEOUT, LockManager
12
14
  from .models.project_config import ProjectConfig
13
15
  from .models.user_config import UserConfig
14
16
 
@@ -22,13 +24,23 @@ class FMUDirectoryBase:
22
24
 
23
25
  config: FMUConfigManager
24
26
  _lock: LockManager
25
-
26
- def __init__(self: Self, base_path: str | Path) -> None:
27
+ _cache_manager: CacheManager
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:
27
37
  """Initializes access to a .fmu directory.
28
38
 
29
39
  Args:
30
40
  base_path: The directory containing the .fmu directory or one of its parent
31
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.
32
44
 
33
45
  Raises:
34
46
  FileExistsError: If .fmu exists but is not a directory
@@ -37,7 +49,8 @@ class FMUDirectoryBase:
37
49
  """
38
50
  self.base_path = Path(base_path).resolve()
39
51
  logger.debug(f"Initializing FMUDirectory from '{base_path}'")
40
- self._lock = LockManager(self)
52
+ self._lock = LockManager(self, timeout_seconds=lock_timeout_seconds)
53
+ self._cache_manager = CacheManager(self, max_revisions=cache_revisions)
41
54
 
42
55
  fmu_dir = self.base_path / ".fmu"
43
56
  if fmu_dir.exists():
@@ -57,6 +70,28 @@ class FMUDirectoryBase:
57
70
  """Returns the path to the .fmu directory."""
58
71
  return self._path
59
72
 
73
+ @property
74
+ def cache(self: Self) -> CacheManager:
75
+ """Access the cache manager."""
76
+ return self._cache_manager
77
+
78
+ @property
79
+ def cache_max_revisions(self: Self) -> int:
80
+ """Current retention limit for revision snapshots."""
81
+ return self._cache_manager.max_revisions
82
+
83
+ @cache_max_revisions.setter
84
+ def cache_max_revisions(self: Self, value: int) -> None:
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)
94
+
60
95
  def get_config_value(self: Self, key: str, default: Any = None) -> Any:
61
96
  """Gets a configuration value by key.
62
97
 
@@ -212,14 +247,59 @@ class FMUDirectoryBase:
212
247
  """
213
248
  return self.get_file_path(relative_path).exists()
214
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
+
215
271
 
216
272
  class ProjectFMUDirectory(FMUDirectoryBase):
217
- config: ProjectConfigManager
273
+ if TYPE_CHECKING:
274
+ config: ProjectConfigManager
275
+
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.
218
285
 
219
- def __init__(self, base_path: str | Path) -> None:
220
- """Initializes a project-based .fmu directory."""
286
+ Args:
287
+ base_path: Project directory containing the .fmu folder.
288
+ lock_timeout_seconds: Lock expiration time in seconds. Default 20 minutes.
289
+ """
221
290
  self.config = ProjectConfigManager(self)
222
- 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
223
303
 
224
304
  def update_config(self: Self, updates: dict[str, Any]) -> ProjectConfig:
225
305
  """Updates multiple configuration values at once.
@@ -287,12 +367,34 @@ class ProjectFMUDirectory(FMUDirectoryBase):
287
367
 
288
368
 
289
369
  class UserFMUDirectory(FMUDirectoryBase):
290
- config: UserConfigManager
370
+ if TYPE_CHECKING:
371
+ config: UserConfigManager
291
372
 
292
- def __init__(self) -> None:
293
- """Initializes a project-based .fmu directory."""
373
+ _README_CONTENT: str = USER_README_CONTENT
374
+
375
+ def __init__(
376
+ self: Self,
377
+ *,
378
+ lock_timeout_seconds: int = DEFAULT_LOCK_TIMEOUT,
379
+ ) -> None:
380
+ """Initializes a user .fmu directory.
381
+
382
+ Args:
383
+ lock_timeout_seconds: Lock expiration time in seconds. Default 20 minutes.
384
+ """
294
385
  self.config = UserConfigManager(self)
295
- super().__init__(Path.home())
386
+ super().__init__(
387
+ Path.home(),
388
+ CacheManager.MIN_REVISIONS,
389
+ lock_timeout_seconds=lock_timeout_seconds,
390
+ )
391
+ try:
392
+ max_revisions = self.config.get(
393
+ "cache_max_revisions", CacheManager.MIN_REVISIONS
394
+ )
395
+ self._cache_manager.max_revisions = max_revisions
396
+ except FileNotFoundError:
397
+ pass
296
398
 
297
399
  def update_config(self: Self, updates: dict[str, Any]) -> UserConfig:
298
400
  """Updates multiple configuration values at once.
@@ -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
+ )
@@ -0,0 +1,153 @@
1
+ """Utilities for storing revision snapshots of .fmu files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, ClassVar, Final, Self
8
+ from uuid import uuid4
9
+
10
+ from fmu.settings._logging import null_logger
11
+
12
+ if TYPE_CHECKING:
13
+ from fmu.settings._fmu_dir import FMUDirectoryBase
14
+
15
+ logger: Final = null_logger(__name__)
16
+
17
+ _CACHEDIR_TAG_CONTENT: Final = (
18
+ "Signature: 8a477f597d28d172789f06886806bc55\n"
19
+ "# This directory contains cached FMU files.\n"
20
+ "# For information about cache directory tags, see:\n"
21
+ "# https://bford.info/cachedir/spec.html"
22
+ )
23
+
24
+
25
+ class CacheManager:
26
+ """Stores complete file revisions under the `.fmu/cache` tree."""
27
+
28
+ MIN_REVISIONS: ClassVar[int] = 5
29
+
30
+ def __init__(
31
+ self: Self,
32
+ fmu_dir: FMUDirectoryBase,
33
+ max_revisions: int = 5,
34
+ ) -> None:
35
+ """Initialize the cache manager.
36
+
37
+ Args:
38
+ fmu_dir: The FMUDirectory instance.
39
+ max_revisions: Maximum number of revisions to retain. Default is 5.
40
+ Values below 5 are set to 5.
41
+ """
42
+ self._fmu_dir = fmu_dir
43
+ self._cache_root = Path("cache")
44
+ self._max_revisions = max(self.MIN_REVISIONS, max_revisions)
45
+
46
+ @property
47
+ def max_revisions(self: Self) -> int:
48
+ """Maximum number of revisions retained per resource."""
49
+ return self._max_revisions
50
+
51
+ @max_revisions.setter
52
+ def max_revisions(self: Self, value: int) -> None:
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)
60
+
61
+ def store_revision(
62
+ self: Self,
63
+ resource_file_path: Path | str,
64
+ content: str,
65
+ encoding: str = "utf-8",
66
+ ) -> Path | None:
67
+ """Write a full snapshot of the resource file to the cache directory.
68
+
69
+ Args:
70
+ resource_file_path: Relative path within the ``.fmu`` directory (e.g.,
71
+ ``config.json``) of the resource file being cached.
72
+ content: Serialized payload to store.
73
+ encoding: Encoding used when persisting the snapshot. Defaults to UTF-8.
74
+
75
+ Returns:
76
+ Absolute filesystem path to the stored snapshot.
77
+ """
78
+ resource_file_path = Path(resource_file_path)
79
+ cache_dir = self._ensure_resource_cache_dir(resource_file_path)
80
+ snapshot_name = self._snapshot_filename(resource_file_path)
81
+ snapshot_path = cache_dir / snapshot_name
82
+
83
+ cache_relative = self._cache_root / resource_file_path.stem
84
+ self._fmu_dir.write_text_file(
85
+ cache_relative / snapshot_name, content, encoding=encoding
86
+ )
87
+ logger.debug("Stored revision snapshot at %s", snapshot_path)
88
+
89
+ self._trim(cache_dir)
90
+ return snapshot_path
91
+
92
+ def list_revisions(self: Self, resource_file_path: Path | str) -> list[Path]:
93
+ """List existing snapshots for a resource file, sorted oldest to newest.
94
+
95
+ Args:
96
+ resource_file_path: Relative path within the ``.fmu`` directory (e.g.,
97
+ ``config.json``) whose cache entries should be listed.
98
+
99
+ Returns:
100
+ A list of absolute `Path` objects sorted oldest to newest.
101
+ """
102
+ resource_file_path = Path(resource_file_path)
103
+ cache_relative = self._cache_root / resource_file_path.stem
104
+ if not self._fmu_dir.file_exists(cache_relative):
105
+ return []
106
+ cache_dir = self._fmu_dir.get_file_path(cache_relative)
107
+
108
+ revisions = [p for p in cache_dir.iterdir() if p.is_file()]
109
+ revisions.sort(key=lambda path: path.name)
110
+ return revisions
111
+
112
+ def _ensure_resource_cache_dir(self: Self, resource_file_path: Path) -> Path:
113
+ """Create (if needed) and return the cache directory for resource file."""
114
+ self._cache_root_path(create=True)
115
+ resource_cache_dir_relative = self._cache_root / resource_file_path.stem
116
+ return self._fmu_dir.ensure_directory(resource_cache_dir_relative)
117
+
118
+ def _cache_root_path(self: Self, create: bool) -> Path:
119
+ """Resolve the cache root, creating it and the cachedir tag if requested."""
120
+ if create:
121
+ cache_root = self._fmu_dir.ensure_directory(self._cache_root)
122
+ self._ensure_cachedir_tag()
123
+ return cache_root
124
+
125
+ return self._fmu_dir.get_file_path(self._cache_root)
126
+
127
+ def _ensure_cachedir_tag(self: Self) -> None:
128
+ """Ensure the cache root complies with the Cachedir specification."""
129
+ tag_path_relative = self._cache_root / "CACHEDIR.TAG"
130
+ if self._fmu_dir.file_exists(tag_path_relative):
131
+ return
132
+ self._fmu_dir.write_text_file(tag_path_relative, _CACHEDIR_TAG_CONTENT)
133
+
134
+ def _snapshot_filename(self: Self, resource_file_path: Path) -> str:
135
+ """Generate a timestamped filename for the next snapshot."""
136
+ timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ")
137
+ suffix = resource_file_path.suffix or ".txt"
138
+ token = uuid4().hex[:8]
139
+ return f"{timestamp}-{token}{suffix}"
140
+
141
+ def _trim(self: Self, cache_dir: Path) -> None:
142
+ """Remove the oldest snapshots until the retention limit is respected."""
143
+ revisions = [p for p in cache_dir.iterdir() if p.is_file()]
144
+ if len(revisions) <= self.max_revisions:
145
+ return
146
+
147
+ revisions.sort(key=lambda path: path.name)
148
+ excess = len(revisions) - self.max_revisions
149
+ for old_revision in revisions[:excess]:
150
+ try:
151
+ old_revision.unlink()
152
+ except FileNotFoundError:
153
+ continue
@@ -39,6 +39,8 @@ class LockNotFoundError(FileNotFoundError):
39
39
  class LockManager(PydanticResourceManager[LockInfo]):
40
40
  """Manages the .lock file."""
41
41
 
42
+ cache_enabled: bool = False
43
+
42
44
  def __init__(
43
45
  self: Self,
44
46
  fmu_dir: FMUDirectoryBase,
@@ -86,7 +88,7 @@ class LockManager(PydanticResourceManager[LockInfo]):
86
88
  return
87
89
 
88
90
  if not wait:
89
- lock_info = self._safe_load()
91
+ lock_info = self.safe_load()
90
92
  if lock_info:
91
93
  raise LockError(
92
94
  f"Lock file is held by {lock_info.user}@{lock_info.hostname} "
@@ -156,12 +158,16 @@ class LockManager(PydanticResourceManager[LockInfo]):
156
158
  with contextlib.suppress(OSError):
157
159
  temp_path.unlink()
158
160
 
159
- def is_locked(self: Self) -> bool:
161
+ def is_locked(self: Self, *, propagate_errors: bool = False) -> bool:
160
162
  """Returns whether or not the lock is locked by anyone.
161
163
 
162
164
  This does a force load on the lock file.
163
165
  """
164
- lock_info = self._safe_load(force=True)
166
+ lock_info = (
167
+ self.load(force=True, store_cache=False)
168
+ if propagate_errors
169
+ else self.safe_load(force=True, store_cache=False)
170
+ )
165
171
  if not lock_info:
166
172
  return False
167
173
  return time.time() < lock_info.expires_at
@@ -170,15 +176,16 @@ class LockManager(PydanticResourceManager[LockInfo]):
170
176
  """Returns whether or not the lock is currently acquired by this instance."""
171
177
  if self._cache is None or self._acquired_at is None:
172
178
  return False
173
- return self._is_mine(self._cache) and not self._is_stale()
179
+
180
+ current_lock = self.safe_load(force=True, store_cache=False)
181
+ if current_lock is None:
182
+ return False
183
+
184
+ return self._is_mine(current_lock) and not self._is_stale()
174
185
 
175
186
  def ensure_can_write(self: Self) -> None:
176
187
  """Raise PermissionError if another process currently holds the lock."""
177
- try:
178
- lock_info = self.load(force=True, store_cache=False)
179
- except Exception:
180
- lock_info = None
181
-
188
+ lock_info = self.safe_load(force=True, store_cache=False)
182
189
  if (
183
190
  self.exists
184
191
  and lock_info is not None
@@ -202,7 +209,7 @@ class LockManager(PydanticResourceManager[LockInfo]):
202
209
  self.release()
203
210
  raise LockNotFoundError("Cannot refresh: lock file does not exist")
204
211
 
205
- lock_info = self._safe_load()
212
+ lock_info = self.safe_load()
206
213
  if not lock_info or not self._is_mine(lock_info):
207
214
  raise LockError(
208
215
  "Cannot refresh: lock file is held by another process or host."
@@ -214,7 +221,7 @@ class LockManager(PydanticResourceManager[LockInfo]):
214
221
  def release(self: Self) -> None:
215
222
  """Release the lock."""
216
223
  if self.exists:
217
- lock_info = self._safe_load()
224
+ lock_info = self.safe_load()
218
225
  if lock_info and self._is_mine(lock_info):
219
226
  with contextlib.suppress(ValueError):
220
227
  self.path.unlink()
@@ -222,12 +229,15 @@ class LockManager(PydanticResourceManager[LockInfo]):
222
229
  self._acquired_at = None
223
230
  self._cache = None
224
231
 
225
- def save(self: Self, data: LockInfo) -> None:
232
+ def save(
233
+ self: Self,
234
+ data: LockInfo,
235
+ ) -> None:
226
236
  """Save the lockfile in an NFS-atomic manner.
227
237
 
228
238
  This overrides save() from the Pydantic resource manager.
229
239
  """
230
- lock_info = self._safe_load()
240
+ lock_info = self.safe_load()
231
241
  if not lock_info or not self._is_mine(lock_info):
232
242
  raise LockError(
233
243
  "Failed to save lock: lock file is held by another process or host."
@@ -254,20 +264,22 @@ class LockManager(PydanticResourceManager[LockInfo]):
254
264
  and lock_info.acquired_at == self._acquired_at
255
265
  )
256
266
 
257
- def _safe_load(self: Self, force: bool = False) -> LockInfo | None:
267
+ def safe_load(
268
+ self: Self, force: bool = False, store_cache: bool = False
269
+ ) -> LockInfo | None:
258
270
  """Load lock info, returning None if corrupted.
259
271
 
260
272
  Because this file does not exist in a static state, wrap around loading it.
261
273
  """
262
274
  try:
263
- return self.load(force=force)
275
+ return self.load(force=force, store_cache=store_cache)
264
276
  except Exception:
265
277
  return None
266
278
 
267
279
  def _is_stale(self: Self, lock_info: LockInfo | None = None) -> bool:
268
280
  """Check if existing lock is stale (expired or process dead)."""
269
281
  if lock_info is None:
270
- lock_info = self._safe_load()
282
+ lock_info = self.safe_load()
271
283
 
272
284
  if not lock_info:
273
285
  return True
@@ -22,6 +22,8 @@ MutablePydanticResource = TypeVar("MutablePydanticResource", bound=ResettableBas
22
22
  class PydanticResourceManager(Generic[PydanticResource]):
23
23
  """Base class for managing resources represented by Pydantic models."""
24
24
 
25
+ cache_enabled: bool = True
26
+
25
27
  def __init__(
26
28
  self: Self, fmu_dir: FMUDirectoryBase, model_class: type[PydanticResource]
27
29
  ) -> None:
@@ -99,15 +101,22 @@ class PydanticResourceManager(Generic[PydanticResource]):
99
101
 
100
102
  return self._cache
101
103
 
102
- def save(self: Self, model: PydanticResource) -> None:
104
+ def save(
105
+ self: Self,
106
+ model: PydanticResource,
107
+ ) -> None:
103
108
  """Save the Pydantic model to disk.
104
109
 
105
110
  Args:
106
- model: Validated Pydantic model instance
111
+ model: Validated Pydantic model instance.
107
112
  """
108
113
  self.fmu_dir._lock.ensure_can_write()
109
114
  json_data = model.model_dump_json(by_alias=True, indent=2)
110
115
  self.fmu_dir.write_text_file(self.relative_path, json_data)
116
+
117
+ if self.cache_enabled and self.exists:
118
+ self.fmu_dir.cache.store_revision(self.relative_path, json_data)
119
+
111
120
  self._cache = model
112
121
 
113
122
 
@@ -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.3'
32
- __version_tuple__ = version_tuple = (0, 5, 3)
31
+ __version__ = version = '0.6.0'
32
+ __version_tuple__ = version_tuple = (0, 6, 0)
33
33
 
34
- __commit_id__ = commit_id = 'g7953e1c0f'
34
+ __commit_id__ = commit_id = 'g746436036'
@@ -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
  )