fmu-settings 0.5.3__tar.gz → 0.5.4__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.
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/PKG-INFO +1 -1
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/settings/_fmu_dir.py +23 -3
- fmu_settings-0.5.4/src/fmu/settings/_resources/cache_manager.py +149 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/settings/_resources/lock_manager.py +28 -16
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/settings/_resources/pydantic_resource_manager.py +11 -2
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/settings/_version.py +3 -3
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu_settings.egg-info/PKG-INFO +1 -1
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu_settings.egg-info/SOURCES.txt +2 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/tests/test_fmu_dir.py +19 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/tests/test_global_config.py +20 -20
- fmu_settings-0.5.4/tests/test_resources/test_cache_manager.py +116 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/tests/test_resources/test_lock_manager.py +17 -5
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/tests/test_resources/test_resource_managers.py +100 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/.coveragerc +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/.github/pull_request_template.md +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/.github/workflows/ci.yml +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/.github/workflows/codeql.yml +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/.github/workflows/publish.yml +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/.gitignore +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/CONTRIBUTING.md +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/LICENSE +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/README.md +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/SECURITY.md +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/pyproject.toml +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/setup.cfg +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/__init__.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/settings/__init__.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/settings/_global_config.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/settings/_init.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/settings/_logging.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/settings/_resources/__init__.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/settings/_resources/config_managers.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/settings/models/__init__.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/settings/models/_enums.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/settings/models/_mappings.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/settings/models/lock_info.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/settings/models/project_config.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/settings/models/user_config.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/settings/py.typed +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/settings/types.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu_settings.egg-info/dependency_links.txt +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu_settings.egg-info/requires.txt +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu_settings.egg-info/top_level.txt +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/tests/conftest.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/tests/test_init.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/tests/test_resources/test_project_config.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.5.4}/tests/test_resources/test_user_config.py +0 -0
|
@@ -1,9 +1,10 @@
|
|
|
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 ._resources.cache_manager import CacheManager
|
|
7
8
|
from ._resources.config_managers import (
|
|
8
9
|
ProjectConfigManager,
|
|
9
10
|
UserConfigManager,
|
|
@@ -22,6 +23,7 @@ class FMUDirectoryBase:
|
|
|
22
23
|
|
|
23
24
|
config: FMUConfigManager
|
|
24
25
|
_lock: LockManager
|
|
26
|
+
_cache_manager: CacheManager
|
|
25
27
|
|
|
26
28
|
def __init__(self: Self, base_path: str | Path) -> None:
|
|
27
29
|
"""Initializes access to a .fmu directory.
|
|
@@ -38,6 +40,7 @@ class FMUDirectoryBase:
|
|
|
38
40
|
self.base_path = Path(base_path).resolve()
|
|
39
41
|
logger.debug(f"Initializing FMUDirectory from '{base_path}'")
|
|
40
42
|
self._lock = LockManager(self)
|
|
43
|
+
self._cache_manager = CacheManager(self, max_revisions=5)
|
|
41
44
|
|
|
42
45
|
fmu_dir = self.base_path / ".fmu"
|
|
43
46
|
if fmu_dir.exists():
|
|
@@ -57,6 +60,21 @@ class FMUDirectoryBase:
|
|
|
57
60
|
"""Returns the path to the .fmu directory."""
|
|
58
61
|
return self._path
|
|
59
62
|
|
|
63
|
+
@property
|
|
64
|
+
def cache(self: Self) -> CacheManager:
|
|
65
|
+
"""Access the cache manager."""
|
|
66
|
+
return self._cache_manager
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def cache_max_revisions(self: Self) -> int:
|
|
70
|
+
"""Current retention limit for revision snapshots."""
|
|
71
|
+
return self._cache_manager.max_revisions
|
|
72
|
+
|
|
73
|
+
@cache_max_revisions.setter
|
|
74
|
+
def cache_max_revisions(self: Self, value: int) -> None:
|
|
75
|
+
"""Update the retention limit for revision snapshots."""
|
|
76
|
+
self._cache_manager.max_revisions = value
|
|
77
|
+
|
|
60
78
|
def get_config_value(self: Self, key: str, default: Any = None) -> Any:
|
|
61
79
|
"""Gets a configuration value by key.
|
|
62
80
|
|
|
@@ -214,7 +232,8 @@ class FMUDirectoryBase:
|
|
|
214
232
|
|
|
215
233
|
|
|
216
234
|
class ProjectFMUDirectory(FMUDirectoryBase):
|
|
217
|
-
|
|
235
|
+
if TYPE_CHECKING:
|
|
236
|
+
config: ProjectConfigManager
|
|
218
237
|
|
|
219
238
|
def __init__(self, base_path: str | Path) -> None:
|
|
220
239
|
"""Initializes a project-based .fmu directory."""
|
|
@@ -287,7 +306,8 @@ class ProjectFMUDirectory(FMUDirectoryBase):
|
|
|
287
306
|
|
|
288
307
|
|
|
289
308
|
class UserFMUDirectory(FMUDirectoryBase):
|
|
290
|
-
|
|
309
|
+
if TYPE_CHECKING:
|
|
310
|
+
config: UserConfigManager
|
|
291
311
|
|
|
292
312
|
def __init__(self) -> None:
|
|
293
313
|
"""Initializes a project-based .fmu directory."""
|
|
@@ -0,0 +1,149 @@
|
|
|
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, 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
|
+
def __init__(
|
|
29
|
+
self: Self,
|
|
30
|
+
fmu_dir: FMUDirectoryBase,
|
|
31
|
+
max_revisions: int = 5,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Initialize the cache manager.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
fmu_dir: The FMUDirectory instance.
|
|
37
|
+
max_revisions: Maximum number of revisions to retain. Default is 5.
|
|
38
|
+
"""
|
|
39
|
+
self._fmu_dir = fmu_dir
|
|
40
|
+
self._cache_root = Path("cache")
|
|
41
|
+
self._max_revisions = max(0, max_revisions)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def max_revisions(self: Self) -> int:
|
|
45
|
+
"""Maximum number of revisions retained per resource."""
|
|
46
|
+
return self._max_revisions
|
|
47
|
+
|
|
48
|
+
@max_revisions.setter
|
|
49
|
+
def max_revisions(self: Self, value: int) -> None:
|
|
50
|
+
"""Update the per-resource revision retention."""
|
|
51
|
+
self._max_revisions = max(0, value)
|
|
52
|
+
|
|
53
|
+
def store_revision(
|
|
54
|
+
self: Self,
|
|
55
|
+
resource_file_path: Path | str,
|
|
56
|
+
content: str,
|
|
57
|
+
encoding: str = "utf-8",
|
|
58
|
+
) -> Path | None:
|
|
59
|
+
"""Write a full snapshot of the resource file to the cache directory.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
resource_file_path: Relative path within the ``.fmu`` directory (e.g.,
|
|
63
|
+
``config.json``) of the resource file being cached.
|
|
64
|
+
content: Serialized payload to store.
|
|
65
|
+
encoding: Encoding used when persisting the snapshot. Defaults to UTF-8.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Absolute filesystem path to the stored snapshot, or ``None`` if caching is
|
|
69
|
+
disabled (``max_revisions`` equals zero).
|
|
70
|
+
"""
|
|
71
|
+
if self.max_revisions == 0:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
resource_file_path = Path(resource_file_path)
|
|
75
|
+
cache_dir = self._ensure_resource_cache_dir(resource_file_path)
|
|
76
|
+
snapshot_name = self._snapshot_filename(resource_file_path)
|
|
77
|
+
snapshot_path = cache_dir / snapshot_name
|
|
78
|
+
|
|
79
|
+
cache_relative = self._cache_root / resource_file_path.stem
|
|
80
|
+
self._fmu_dir.write_text_file(
|
|
81
|
+
cache_relative / snapshot_name, content, encoding=encoding
|
|
82
|
+
)
|
|
83
|
+
logger.debug("Stored revision snapshot at %s", snapshot_path)
|
|
84
|
+
|
|
85
|
+
self._trim(cache_dir)
|
|
86
|
+
return snapshot_path
|
|
87
|
+
|
|
88
|
+
def list_revisions(self: Self, resource_file_path: Path | str) -> list[Path]:
|
|
89
|
+
"""List existing snapshots for a resource file, sorted oldest to newest.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
resource_file_path: Relative path within the ``.fmu`` directory (e.g.,
|
|
93
|
+
``config.json``) whose cache entries should be listed.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
A list of absolute `Path` objects sorted oldest to newest.
|
|
97
|
+
"""
|
|
98
|
+
resource_file_path = Path(resource_file_path)
|
|
99
|
+
cache_relative = self._cache_root / resource_file_path.stem
|
|
100
|
+
if not self._fmu_dir.file_exists(cache_relative):
|
|
101
|
+
return []
|
|
102
|
+
cache_dir = self._fmu_dir.get_file_path(cache_relative)
|
|
103
|
+
|
|
104
|
+
revisions = [p for p in cache_dir.iterdir() if p.is_file()]
|
|
105
|
+
revisions.sort(key=lambda path: path.name)
|
|
106
|
+
return revisions
|
|
107
|
+
|
|
108
|
+
def _ensure_resource_cache_dir(self: Self, resource_file_path: Path) -> Path:
|
|
109
|
+
"""Create (if needed) and return the cache directory for resource file."""
|
|
110
|
+
self._cache_root_path(create=True)
|
|
111
|
+
resource_cache_dir_relative = self._cache_root / resource_file_path.stem
|
|
112
|
+
return self._fmu_dir.ensure_directory(resource_cache_dir_relative)
|
|
113
|
+
|
|
114
|
+
def _cache_root_path(self: Self, create: bool) -> Path:
|
|
115
|
+
"""Resolve the cache root, creating it and the cachedir tag if requested."""
|
|
116
|
+
if create:
|
|
117
|
+
cache_root = self._fmu_dir.ensure_directory(self._cache_root)
|
|
118
|
+
self._ensure_cachedir_tag()
|
|
119
|
+
return cache_root
|
|
120
|
+
|
|
121
|
+
return self._fmu_dir.get_file_path(self._cache_root)
|
|
122
|
+
|
|
123
|
+
def _ensure_cachedir_tag(self: Self) -> None:
|
|
124
|
+
"""Ensure the cache root complies with the Cachedir specification."""
|
|
125
|
+
tag_path_relative = self._cache_root / "CACHEDIR.TAG"
|
|
126
|
+
if self._fmu_dir.file_exists(tag_path_relative):
|
|
127
|
+
return
|
|
128
|
+
self._fmu_dir.write_text_file(tag_path_relative, _CACHEDIR_TAG_CONTENT)
|
|
129
|
+
|
|
130
|
+
def _snapshot_filename(self: Self, resource_file_path: Path) -> str:
|
|
131
|
+
"""Generate a timestamped filename for the next snapshot."""
|
|
132
|
+
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ")
|
|
133
|
+
suffix = resource_file_path.suffix or ".txt"
|
|
134
|
+
token = uuid4().hex[:8]
|
|
135
|
+
return f"{timestamp}-{token}{suffix}"
|
|
136
|
+
|
|
137
|
+
def _trim(self: Self, cache_dir: Path) -> None:
|
|
138
|
+
"""Remove the oldest snapshots until the retention limit is respected."""
|
|
139
|
+
revisions = [p for p in cache_dir.iterdir() if p.is_file()]
|
|
140
|
+
if len(revisions) <= self.max_revisions:
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
revisions.sort(key=lambda path: path.name)
|
|
144
|
+
excess = len(revisions) - self.max_revisions
|
|
145
|
+
for old_revision in revisions[:excess]:
|
|
146
|
+
try:
|
|
147
|
+
old_revision.unlink()
|
|
148
|
+
except FileNotFoundError:
|
|
149
|
+
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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(
|
|
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.
|
|
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
|
|
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.
|
|
282
|
+
lock_info = self.safe_load()
|
|
271
283
|
|
|
272
284
|
if not lock_info:
|
|
273
285
|
return True
|
{fmu_settings-0.5.3 → fmu_settings-0.5.4}/src/fmu/settings/_resources/pydantic_resource_manager.py
RENAMED
|
@@ -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(
|
|
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.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 5,
|
|
31
|
+
__version__ = version = '0.5.4'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 5, 4)
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
34
|
+
__commit_id__ = commit_id = 'g118a9d2da'
|
|
@@ -19,6 +19,7 @@ src/fmu/settings/_version.py
|
|
|
19
19
|
src/fmu/settings/py.typed
|
|
20
20
|
src/fmu/settings/types.py
|
|
21
21
|
src/fmu/settings/_resources/__init__.py
|
|
22
|
+
src/fmu/settings/_resources/cache_manager.py
|
|
22
23
|
src/fmu/settings/_resources/config_managers.py
|
|
23
24
|
src/fmu/settings/_resources/lock_manager.py
|
|
24
25
|
src/fmu/settings/_resources/pydantic_resource_manager.py
|
|
@@ -37,6 +38,7 @@ tests/conftest.py
|
|
|
37
38
|
tests/test_fmu_dir.py
|
|
38
39
|
tests/test_global_config.py
|
|
39
40
|
tests/test_init.py
|
|
41
|
+
tests/test_resources/test_cache_manager.py
|
|
40
42
|
tests/test_resources/test_lock_manager.py
|
|
41
43
|
tests/test_resources/test_project_config.py
|
|
42
44
|
tests/test_resources/test_resource_managers.py
|
|
@@ -110,6 +110,25 @@ def test_find_nearest_not_found(tmp_path: Path, monkeypatch: MonkeyPatch) -> Non
|
|
|
110
110
|
ProjectFMUDirectory.find_nearest(tmp_path)
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
def test_cache_property_returns_cached_manager(fmu_dir: ProjectFMUDirectory) -> None:
|
|
114
|
+
"""Cache manager should be memoized and ready for use."""
|
|
115
|
+
cache = fmu_dir.cache
|
|
116
|
+
|
|
117
|
+
assert cache is fmu_dir.cache
|
|
118
|
+
assert fmu_dir._cache_manager is cache
|
|
119
|
+
assert cache.max_revisions == 5 # noqa: PLR2004
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_set_cache_max_revisions_updates_manager(
|
|
123
|
+
fmu_dir: ProjectFMUDirectory,
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Changing retention should update the existing cache manager."""
|
|
126
|
+
cache = fmu_dir.cache
|
|
127
|
+
fmu_dir.cache_max_revisions = 7
|
|
128
|
+
|
|
129
|
+
assert cache.max_revisions == 7 # noqa: PLR2004
|
|
130
|
+
|
|
131
|
+
|
|
113
132
|
def test_get_config_value(fmu_dir: ProjectFMUDirectory) -> None:
|
|
114
133
|
"""Tests get_config_value retrieves correctly from the config."""
|
|
115
134
|
assert fmu_dir.get_config_value("version") == __version__
|
|
@@ -56,8 +56,8 @@ def test_validate_global_config_strict_model(
|
|
|
56
56
|
generate_strict_valid_globalconfiguration: Callable[[], GlobalConfiguration],
|
|
57
57
|
) -> None:
|
|
58
58
|
"""Tests strict validation on 'model'."""
|
|
59
|
-
cfg = generate_strict_valid_globalconfiguration(
|
|
60
|
-
model=fields.Model(name=name, revision=""),
|
|
59
|
+
cfg = generate_strict_valid_globalconfiguration(
|
|
60
|
+
model=fields.Model(name=name, revision=""), # type: ignore
|
|
61
61
|
)
|
|
62
62
|
if valid:
|
|
63
63
|
validate_global_configuration_strictly(cfg) # Does not raise
|
|
@@ -75,8 +75,8 @@ def test_validate_global_config_strict_access(
|
|
|
75
75
|
generate_strict_valid_globalconfiguration: Callable[[], GlobalConfiguration],
|
|
76
76
|
) -> None:
|
|
77
77
|
"""Tests strict validation on 'access'."""
|
|
78
|
-
cfg = generate_strict_valid_globalconfiguration(
|
|
79
|
-
asset=fields.Asset(name=name),
|
|
78
|
+
cfg = generate_strict_valid_globalconfiguration(
|
|
79
|
+
asset=fields.Asset(name=name), # type: ignore
|
|
80
80
|
)
|
|
81
81
|
if valid:
|
|
82
82
|
validate_global_configuration_strictly(cfg) # Does not raise
|
|
@@ -95,8 +95,8 @@ def test_validate_global_config_strict_smda_country_uuid(
|
|
|
95
95
|
generate_strict_valid_globalconfiguration: Callable[[], GlobalConfiguration],
|
|
96
96
|
) -> None:
|
|
97
97
|
"""Tests strict validation on 'smda.country' uuids."""
|
|
98
|
-
cfg = generate_strict_valid_globalconfiguration(
|
|
99
|
-
country_items=[
|
|
98
|
+
cfg = generate_strict_valid_globalconfiguration(
|
|
99
|
+
country_items=[ # type: ignore
|
|
100
100
|
fields.CountryItem(identifier="bar", uuid=uuid),
|
|
101
101
|
fields.CountryItem(identifier="foo", uuid=uuid4()),
|
|
102
102
|
],
|
|
@@ -118,8 +118,8 @@ def test_validate_global_config_strict_smda_discovery_identifier(
|
|
|
118
118
|
generate_strict_valid_globalconfiguration: Callable[[], GlobalConfiguration],
|
|
119
119
|
) -> None:
|
|
120
120
|
"""Tests strict validation on 'smda.discovery' identifiers."""
|
|
121
|
-
cfg = generate_strict_valid_globalconfiguration(
|
|
122
|
-
discovery_items=[
|
|
121
|
+
cfg = generate_strict_valid_globalconfiguration(
|
|
122
|
+
discovery_items=[ # type: ignore
|
|
123
123
|
fields.DiscoveryItem(short_identifier=identifier, uuid=uuid4()),
|
|
124
124
|
fields.DiscoveryItem(short_identifier="foo", uuid=uuid4()),
|
|
125
125
|
],
|
|
@@ -141,8 +141,8 @@ def test_validate_global_config_strict_smda_discovery_uuid(
|
|
|
141
141
|
generate_strict_valid_globalconfiguration: Callable[[], GlobalConfiguration],
|
|
142
142
|
) -> None:
|
|
143
143
|
"""Tests strict validation on 'smda.discovery' uuids."""
|
|
144
|
-
cfg = generate_strict_valid_globalconfiguration(
|
|
145
|
-
discovery_items=[
|
|
144
|
+
cfg = generate_strict_valid_globalconfiguration(
|
|
145
|
+
discovery_items=[ # type: ignore
|
|
146
146
|
fields.DiscoveryItem(short_identifier="bar", uuid=uuid),
|
|
147
147
|
fields.DiscoveryItem(short_identifier="foo", uuid=uuid4()),
|
|
148
148
|
],
|
|
@@ -164,8 +164,8 @@ def test_validate_global_config_strict_smda_field_identifier(
|
|
|
164
164
|
generate_strict_valid_globalconfiguration: Callable[[], GlobalConfiguration],
|
|
165
165
|
) -> None:
|
|
166
166
|
"""Tests strict validation on 'smda.discovery' identifiers."""
|
|
167
|
-
cfg = generate_strict_valid_globalconfiguration(
|
|
168
|
-
field_items=[
|
|
167
|
+
cfg = generate_strict_valid_globalconfiguration(
|
|
168
|
+
field_items=[ # type: ignore
|
|
169
169
|
fields.FieldItem(identifier=identifier, uuid=uuid4()),
|
|
170
170
|
fields.FieldItem(identifier="foo", uuid=uuid4()),
|
|
171
171
|
],
|
|
@@ -187,8 +187,8 @@ def test_validate_global_config_strict_smda_field_uuid(
|
|
|
187
187
|
generate_strict_valid_globalconfiguration: Callable[[], GlobalConfiguration],
|
|
188
188
|
) -> None:
|
|
189
189
|
"""Tests strict validation on 'smda.discovery' uuids."""
|
|
190
|
-
cfg = generate_strict_valid_globalconfiguration(
|
|
191
|
-
field_items=[
|
|
190
|
+
cfg = generate_strict_valid_globalconfiguration(
|
|
191
|
+
field_items=[ # type: ignore
|
|
192
192
|
fields.FieldItem(identifier="bar", uuid=uuid),
|
|
193
193
|
fields.FieldItem(identifier="foo", uuid=uuid4()),
|
|
194
194
|
],
|
|
@@ -210,8 +210,8 @@ def test_validate_global_config_strict_coordinate_system(
|
|
|
210
210
|
generate_strict_valid_globalconfiguration: Callable[[], GlobalConfiguration],
|
|
211
211
|
) -> None:
|
|
212
212
|
"""Tests strict validation on 'smda.coordinate_system'."""
|
|
213
|
-
cfg = generate_strict_valid_globalconfiguration(
|
|
214
|
-
coordinate_system=fields.CoordinateSystem(identifier="", uuid=uuid),
|
|
213
|
+
cfg = generate_strict_valid_globalconfiguration(
|
|
214
|
+
coordinate_system=fields.CoordinateSystem(identifier="", uuid=uuid), # type: ignore
|
|
215
215
|
)
|
|
216
216
|
if valid:
|
|
217
217
|
validate_global_configuration_strictly(cfg) # Does not raise
|
|
@@ -230,8 +230,8 @@ def test_validate_global_config_strict_stratigraphic_column_uuids(
|
|
|
230
230
|
generate_strict_valid_globalconfiguration: Callable[[], GlobalConfiguration],
|
|
231
231
|
) -> None:
|
|
232
232
|
"""Tests strict validation on 'smda.stratigraphic_column' uuid."""
|
|
233
|
-
cfg = generate_strict_valid_globalconfiguration(
|
|
234
|
-
stratigraphic_column=fields.StratigraphicColumn(identifier="", uuid=uuid),
|
|
233
|
+
cfg = generate_strict_valid_globalconfiguration(
|
|
234
|
+
stratigraphic_column=fields.StratigraphicColumn(identifier="", uuid=uuid), # type: ignore
|
|
235
235
|
)
|
|
236
236
|
if valid:
|
|
237
237
|
validate_global_configuration_strictly(cfg) # Does not raise
|
|
@@ -250,8 +250,8 @@ def test_validate_global_config_strict_stratigraphic_column_names(
|
|
|
250
250
|
generate_strict_valid_globalconfiguration: Callable[[], GlobalConfiguration],
|
|
251
251
|
) -> None:
|
|
252
252
|
"""Tests strict validation on 'smda.stratigraphic_column' identifiers."""
|
|
253
|
-
cfg = generate_strict_valid_globalconfiguration(
|
|
254
|
-
stratigraphic_column=fields.StratigraphicColumn(
|
|
253
|
+
cfg = generate_strict_valid_globalconfiguration(
|
|
254
|
+
stratigraphic_column=fields.StratigraphicColumn( # type: ignore
|
|
255
255
|
identifier=identifier, uuid=uuid4()
|
|
256
256
|
),
|
|
257
257
|
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Tests for the cache manager utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from fmu.settings._resources.cache_manager import _CACHEDIR_TAG_CONTENT, CacheManager
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from fmu.settings._fmu_dir import ProjectFMUDirectory
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _read_snapshot_names(config_cache: Path) -> list[str]:
|
|
17
|
+
return sorted(p.name for p in config_cache.iterdir() if p.is_file())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_cache_manager_list_revisions_without_directory(
|
|
21
|
+
fmu_dir: ProjectFMUDirectory,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Listing revisions on a missing cache dir yields an empty list."""
|
|
24
|
+
manager = CacheManager(fmu_dir)
|
|
25
|
+
assert manager.list_revisions("foo.json") == []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_cache_manager_list_revisions_with_existing_snapshots(
|
|
29
|
+
fmu_dir: ProjectFMUDirectory,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Listing revisions returns sorted snapshot paths."""
|
|
32
|
+
manager = CacheManager(fmu_dir)
|
|
33
|
+
manager.store_revision("foo.json", "one")
|
|
34
|
+
manager.store_revision("foo.json", "two")
|
|
35
|
+
revisions = manager.list_revisions("foo.json")
|
|
36
|
+
assert [path.name for path in revisions] == sorted(path.name for path in revisions)
|
|
37
|
+
assert len(revisions) == 2 # noqa: PLR2004
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_cache_manager_honours_existing_cachedir_tag(
|
|
41
|
+
fmu_dir: ProjectFMUDirectory,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Existing cachedir tags are preserved when storing revisions."""
|
|
44
|
+
cache_root = fmu_dir.path / "cache"
|
|
45
|
+
cache_root.mkdir(exist_ok=True)
|
|
46
|
+
tag_path = cache_root / "CACHEDIR.TAG"
|
|
47
|
+
tag_path.write_text("custom tag", encoding="utf-8")
|
|
48
|
+
|
|
49
|
+
manager = CacheManager(fmu_dir)
|
|
50
|
+
manager.store_revision("foo.json", '{"foo": "bar"}')
|
|
51
|
+
|
|
52
|
+
assert tag_path.read_text(encoding="utf-8") == "custom tag"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_cache_manager_cache_root_helpers_create_tag(
|
|
56
|
+
fmu_dir: ProjectFMUDirectory,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Cache root helpers return consistent paths and create cachedir tags."""
|
|
59
|
+
manager = CacheManager(fmu_dir)
|
|
60
|
+
root = manager._cache_root_path(create=False)
|
|
61
|
+
assert root == fmu_dir.get_file_path("cache")
|
|
62
|
+
|
|
63
|
+
created = manager._cache_root_path(create=True)
|
|
64
|
+
assert created == root
|
|
65
|
+
|
|
66
|
+
tag_path = created / "CACHEDIR.TAG"
|
|
67
|
+
assert tag_path.is_file()
|
|
68
|
+
assert tag_path.read_text(encoding="utf-8") == _CACHEDIR_TAG_CONTENT
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_cache_manager_uses_default_extension_for_suffixless_paths(
|
|
72
|
+
fmu_dir: ProjectFMUDirectory,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Files without suffix get '.txt' snapshots."""
|
|
75
|
+
manager = CacheManager(fmu_dir)
|
|
76
|
+
snapshot = manager.store_revision("logs/entry", "payload")
|
|
77
|
+
assert snapshot is not None
|
|
78
|
+
assert snapshot.suffix == ".txt"
|
|
79
|
+
assert snapshot.read_text(encoding="utf-8") == "payload"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_cache_manager_trim_handles_missing_files(
|
|
83
|
+
fmu_dir: ProjectFMUDirectory,
|
|
84
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Trimming gracefully handles concurrent removals."""
|
|
87
|
+
manager = CacheManager(fmu_dir, max_revisions=1)
|
|
88
|
+
manager.store_revision("foo.json", "first")
|
|
89
|
+
|
|
90
|
+
original_unlink = Path.unlink
|
|
91
|
+
|
|
92
|
+
def flaky_unlink(self: Path, *, missing_ok: bool = False) -> None:
|
|
93
|
+
if self.name.endswith(".json") and not getattr(flaky_unlink, "raised", False):
|
|
94
|
+
flaky_unlink.raised = True # type: ignore[attr-defined]
|
|
95
|
+
original_unlink(self, missing_ok=missing_ok)
|
|
96
|
+
raise FileNotFoundError
|
|
97
|
+
original_unlink(self, missing_ok=missing_ok)
|
|
98
|
+
|
|
99
|
+
monkeypatch.setattr(Path, "unlink", flaky_unlink)
|
|
100
|
+
|
|
101
|
+
manager.store_revision("foo.json", "second")
|
|
102
|
+
|
|
103
|
+
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()
|
|
@@ -288,7 +288,7 @@ def test_is_stale_load_fails(fmu_dir: ProjectFMUDirectory) -> None:
|
|
|
288
288
|
"""Tests is_stale if loading the lock file fails."""
|
|
289
289
|
lock = LockManager(fmu_dir)
|
|
290
290
|
lock.acquire()
|
|
291
|
-
with patch.object(lock, "
|
|
291
|
+
with patch.object(lock, "safe_load", return_value=None):
|
|
292
292
|
assert lock._is_stale() is True
|
|
293
293
|
|
|
294
294
|
|
|
@@ -415,6 +415,18 @@ def test_is_locked_by_other_process(
|
|
|
415
415
|
assert lock.is_locked() is False
|
|
416
416
|
|
|
417
417
|
|
|
418
|
+
def test_is_locked_propagate_errors(
|
|
419
|
+
fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
|
|
420
|
+
) -> None:
|
|
421
|
+
"""Tests that load with propagate errors raises."""
|
|
422
|
+
lock = LockManager(fmu_dir)
|
|
423
|
+
lock.path.write_text("a")
|
|
424
|
+
assert lock.is_locked() is False
|
|
425
|
+
|
|
426
|
+
with pytest.raises(ValueError, match="Invalid JSON"):
|
|
427
|
+
assert lock.is_locked(propagate_errors=True) is False
|
|
428
|
+
|
|
429
|
+
|
|
418
430
|
def test_is_acquired_expected(
|
|
419
431
|
fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
|
|
420
432
|
) -> None:
|
|
@@ -540,11 +552,11 @@ def test_safe_load(fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch) -> No
|
|
|
540
552
|
lock = LockManager(fmu_dir)
|
|
541
553
|
lock.acquire()
|
|
542
554
|
assert lock._cache is not None
|
|
543
|
-
assert lock.
|
|
555
|
+
assert lock.safe_load() == lock._cache
|
|
544
556
|
|
|
545
557
|
lock.release()
|
|
546
558
|
lock.path.write_text("a")
|
|
547
|
-
assert lock.
|
|
559
|
+
assert lock.safe_load() is None
|
|
548
560
|
|
|
549
561
|
|
|
550
562
|
def test_save_expected(fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch) -> None:
|
|
@@ -604,7 +616,7 @@ def test_ensure_can_write_invalid_lock(fmu_dir: ProjectFMUDirectory) -> None:
|
|
|
604
616
|
"""Tests ensure_can_write ignores unreadable lock info."""
|
|
605
617
|
lock = LockManager(fmu_dir)
|
|
606
618
|
lock.path.write_text("garbage")
|
|
607
|
-
with patch.object(lock, "
|
|
619
|
+
with patch.object(lock, "safe_load", return_value=None):
|
|
608
620
|
lock.ensure_can_write()
|
|
609
621
|
|
|
610
622
|
|
|
@@ -629,7 +641,7 @@ def test_ensure_can_write_stale_lock(fmu_dir: ProjectFMUDirectory) -> None:
|
|
|
629
641
|
)
|
|
630
642
|
lock.path.write_text(lock_info.model_dump_json(indent=2))
|
|
631
643
|
with (
|
|
632
|
-
patch.object(lock, "
|
|
644
|
+
patch.object(lock, "safe_load", return_value=lock_info),
|
|
633
645
|
patch.object(lock, "is_acquired", return_value=False),
|
|
634
646
|
patch.object(lock, "_is_stale", return_value=True),
|
|
635
647
|
):
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Tests for fmu.settings.resources.managers."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import shutil
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Self
|
|
6
7
|
from unittest.mock import patch
|
|
@@ -9,6 +10,7 @@ import pytest
|
|
|
9
10
|
from pydantic import BaseModel
|
|
10
11
|
|
|
11
12
|
from fmu.settings._fmu_dir import ProjectFMUDirectory
|
|
13
|
+
from fmu.settings._resources.cache_manager import CacheManager
|
|
12
14
|
from fmu.settings._resources.lock_manager import LockManager
|
|
13
15
|
from fmu.settings._resources.pydantic_resource_manager import PydanticResourceManager
|
|
14
16
|
|
|
@@ -184,3 +186,101 @@ def test_pydantic_resource_manager_loads_invalid_model(
|
|
|
184
186
|
ValueError, match=r"Invalid content in resource file[\s\S]*input_value=0"
|
|
185
187
|
):
|
|
186
188
|
a.load(force=True)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_pydantic_resource_manager_save_does_not_cache_when_disabled(
|
|
192
|
+
fmu_dir: ProjectFMUDirectory,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Saving without cache enabled should not create cache artifacts."""
|
|
195
|
+
original_default = AManager.cache_enabled
|
|
196
|
+
AManager.cache_enabled = False
|
|
197
|
+
cache_root = fmu_dir.path / "cache"
|
|
198
|
+
try:
|
|
199
|
+
if cache_root.exists():
|
|
200
|
+
shutil.rmtree(cache_root)
|
|
201
|
+
a = AManager(fmu_dir)
|
|
202
|
+
a.save(A(foo="bar"))
|
|
203
|
+
finally:
|
|
204
|
+
AManager.cache_enabled = original_default
|
|
205
|
+
|
|
206
|
+
assert not cache_root.exists()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_pydantic_resource_manager_save_stores_revision_when_enabled(
|
|
210
|
+
fmu_dir: ProjectFMUDirectory,
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Saving with cache enabled should persist a revision snapshot."""
|
|
213
|
+
a = AManager(fmu_dir)
|
|
214
|
+
model = A(foo="bar")
|
|
215
|
+
a.save(model)
|
|
216
|
+
|
|
217
|
+
cache_root = fmu_dir.path / "cache"
|
|
218
|
+
assert cache_root.is_dir()
|
|
219
|
+
tag_path = cache_root / "CACHEDIR.TAG"
|
|
220
|
+
assert tag_path.read_text(encoding="utf-8").startswith(
|
|
221
|
+
"Signature: 8a477f597d28d172789f06886806bc55"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
config_cache = cache_root / "foo"
|
|
225
|
+
snapshots = list(config_cache.iterdir())
|
|
226
|
+
assert len(snapshots) == 1
|
|
227
|
+
snapshot = snapshots[0]
|
|
228
|
+
assert snapshot.suffix == ".json"
|
|
229
|
+
assert json.loads(snapshot.read_text(encoding="utf-8")) == model.model_dump()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
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
|
+
fmu_dir: ProjectFMUDirectory,
|
|
265
|
+
) -> None:
|
|
266
|
+
"""Saving uses the cache manager retention setting."""
|
|
267
|
+
original_limit = fmu_dir.cache_max_revisions
|
|
268
|
+
fmu_dir.cache_max_revisions = 3
|
|
269
|
+
try:
|
|
270
|
+
a = AManager(fmu_dir)
|
|
271
|
+
a.save(A(foo="one"))
|
|
272
|
+
a.save(A(foo="two"))
|
|
273
|
+
a.save(A(foo="three"))
|
|
274
|
+
a.save(A(foo="four"))
|
|
275
|
+
finally:
|
|
276
|
+
fmu_dir.cache_max_revisions = original_limit
|
|
277
|
+
|
|
278
|
+
config_cache = fmu_dir.path / "cache" / "foo"
|
|
279
|
+
snapshots = sorted(p.name for p in config_cache.iterdir())
|
|
280
|
+
assert len(snapshots) == 3 # noqa: PLR2004
|
|
281
|
+
|
|
282
|
+
contents = [
|
|
283
|
+
json.loads((config_cache / name).read_text(encoding="utf-8"))["foo"]
|
|
284
|
+
for name in snapshots
|
|
285
|
+
]
|
|
286
|
+
assert contents == ["two", "three", "four"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|