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.
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/PKG-INFO +1 -1
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/_fmu_dir.py +115 -13
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/_init.py +19 -32
- fmu_settings-0.6.0/src/fmu/settings/_readme_texts.py +34 -0
- fmu_settings-0.6.0/src/fmu/settings/_resources/cache_manager.py +153 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/_resources/lock_manager.py +28 -16
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/_resources/pydantic_resource_manager.py +11 -2
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/_version.py +3 -3
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/models/project_config.py +2 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/models/user_config.py +3 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu_settings.egg-info/PKG-INFO +1 -1
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu_settings.egg-info/SOURCES.txt +3 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/tests/conftest.py +3 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/tests/test_fmu_dir.py +102 -2
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/tests/test_global_config.py +20 -20
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/tests/test_init.py +3 -4
- fmu_settings-0.6.0/tests/test_resources/test_cache_manager.py +108 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/tests/test_resources/test_lock_manager.py +35 -5
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/tests/test_resources/test_resource_managers.py +70 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/.coveragerc +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/.github/pull_request_template.md +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/.github/workflows/ci.yml +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/.github/workflows/codeql.yml +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/.github/workflows/publish.yml +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/.gitignore +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/CONTRIBUTING.md +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/LICENSE +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/README.md +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/SECURITY.md +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/pyproject.toml +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/setup.cfg +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/__init__.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/__init__.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/_global_config.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/_logging.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/_resources/__init__.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/_resources/config_managers.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/models/__init__.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/models/_enums.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/models/_mappings.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/models/lock_info.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/py.typed +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu/settings/types.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu_settings.egg-info/dependency_links.txt +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu_settings.egg-info/requires.txt +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/src/fmu_settings.egg-info/top_level.txt +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/tests/test_resources/test_project_config.py +0 -0
- {fmu_settings-0.5.3 → fmu_settings-0.6.0}/tests/test_resources/test_user_config.py +0 -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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__(
|
|
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
|
-
|
|
370
|
+
if TYPE_CHECKING:
|
|
371
|
+
config: UserConfigManager
|
|
291
372
|
|
|
292
|
-
|
|
293
|
-
|
|
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__(
|
|
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(
|
|
102
|
-
|
|
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(
|
|
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",
|
|
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.
|
|
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.6.0}/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.
|
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
|
31
|
+
__version__ = version = '0.6.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 6, 0)
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
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
|
)
|