fmu-settings 0.5.2__py3-none-any.whl → 0.14.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fmu/settings/_fmu_dir.py +251 -19
- fmu/settings/_init.py +19 -32
- fmu/settings/_readme_texts.py +34 -0
- fmu/settings/_resources/cache_manager.py +185 -0
- fmu/settings/_resources/changelog_manager.py +157 -0
- fmu/settings/_resources/config_managers.py +17 -0
- fmu/settings/_resources/lock_manager.py +33 -17
- fmu/settings/_resources/log_manager.py +98 -0
- fmu/settings/_resources/pydantic_resource_manager.py +173 -27
- fmu/settings/_resources/user_session_log_manager.py +47 -0
- fmu/settings/_version.py +2 -2
- fmu/settings/models/_enums.py +18 -0
- fmu/settings/models/change_info.py +37 -0
- fmu/settings/models/event_info.py +15 -0
- fmu/settings/models/log.py +63 -0
- fmu/settings/models/project_config.py +58 -4
- fmu/settings/models/user_config.py +5 -0
- {fmu_settings-0.5.2.dist-info → fmu_settings-0.14.1.dist-info}/METADATA +3 -1
- fmu_settings-0.14.1.dist-info/RECORD +32 -0
- fmu_settings-0.5.2.dist-info/RECORD +0 -24
- {fmu_settings-0.5.2.dist-info → fmu_settings-0.14.1.dist-info}/WHEEL +0 -0
- {fmu_settings-0.5.2.dist-info → fmu_settings-0.14.1.dist-info}/licenses/LICENSE +0 -0
- {fmu_settings-0.5.2.dist-info → fmu_settings-0.14.1.dist-info}/top_level.txt +0 -0
fmu/settings/_fmu_dir.py
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
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
|
+
|
|
6
|
+
from fmu.settings._resources.changelog_manager import ChangelogManager
|
|
5
7
|
|
|
6
8
|
from ._logging import null_logger
|
|
9
|
+
from ._readme_texts import PROJECT_README_CONTENT, USER_README_CONTENT
|
|
10
|
+
from ._resources.cache_manager import CacheManager
|
|
7
11
|
from ._resources.config_managers import (
|
|
8
12
|
ProjectConfigManager,
|
|
9
13
|
UserConfigManager,
|
|
10
14
|
)
|
|
11
|
-
from ._resources.lock_manager import LockManager
|
|
15
|
+
from ._resources.lock_manager import DEFAULT_LOCK_TIMEOUT, LockManager
|
|
12
16
|
from .models.project_config import ProjectConfig
|
|
13
17
|
from .models.user_config import UserConfig
|
|
14
18
|
|
|
@@ -22,13 +26,24 @@ class FMUDirectoryBase:
|
|
|
22
26
|
|
|
23
27
|
config: FMUConfigManager
|
|
24
28
|
_lock: LockManager
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
_cache_manager: CacheManager
|
|
30
|
+
_README_CONTENT: str = ""
|
|
31
|
+
_changelog: ChangelogManager
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self: Self,
|
|
35
|
+
base_path: str | Path,
|
|
36
|
+
cache_revisions: int = CacheManager.MIN_REVISIONS,
|
|
37
|
+
*,
|
|
38
|
+
lock_timeout_seconds: int = DEFAULT_LOCK_TIMEOUT,
|
|
39
|
+
) -> None:
|
|
27
40
|
"""Initializes access to a .fmu directory.
|
|
28
41
|
|
|
29
42
|
Args:
|
|
30
43
|
base_path: The directory containing the .fmu directory or one of its parent
|
|
31
44
|
dirs
|
|
45
|
+
cache_revisions: Number of revisions to retain in the cache. Minimum is 5.
|
|
46
|
+
lock_timeout_seconds: Lock expiration time in seconds. Default 20 minutes.
|
|
32
47
|
|
|
33
48
|
Raises:
|
|
34
49
|
FileExistsError: If .fmu exists but is not a directory
|
|
@@ -37,7 +52,9 @@ class FMUDirectoryBase:
|
|
|
37
52
|
"""
|
|
38
53
|
self.base_path = Path(base_path).resolve()
|
|
39
54
|
logger.debug(f"Initializing FMUDirectory from '{base_path}'")
|
|
40
|
-
self._lock = LockManager(self)
|
|
55
|
+
self._lock = LockManager(self, timeout_seconds=lock_timeout_seconds)
|
|
56
|
+
self._cache_manager = CacheManager(self, max_revisions=cache_revisions)
|
|
57
|
+
self._changelog = ChangelogManager(self)
|
|
41
58
|
|
|
42
59
|
fmu_dir = self.base_path / ".fmu"
|
|
43
60
|
if fmu_dir.exists():
|
|
@@ -57,6 +74,28 @@ class FMUDirectoryBase:
|
|
|
57
74
|
"""Returns the path to the .fmu directory."""
|
|
58
75
|
return self._path
|
|
59
76
|
|
|
77
|
+
@property
|
|
78
|
+
def cache(self: Self) -> CacheManager:
|
|
79
|
+
"""Access the cache manager."""
|
|
80
|
+
return self._cache_manager
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def cache_max_revisions(self: Self) -> int:
|
|
84
|
+
"""Current retention limit for revision snapshots."""
|
|
85
|
+
return self._cache_manager.max_revisions
|
|
86
|
+
|
|
87
|
+
@cache_max_revisions.setter
|
|
88
|
+
def cache_max_revisions(self: Self, value: int) -> None:
|
|
89
|
+
"""Update the retention limit for revision snapshots.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
value: The new maximum number of revisions to retain. Minimum value is 5.
|
|
93
|
+
Values below 5 are set to 5.
|
|
94
|
+
"""
|
|
95
|
+
clamped_value = max(CacheManager.MIN_REVISIONS, value)
|
|
96
|
+
self._cache_manager.max_revisions = clamped_value
|
|
97
|
+
self.set_config_value("cache_max_revisions", clamped_value)
|
|
98
|
+
|
|
60
99
|
def get_config_value(self: Self, key: str, default: Any = None) -> Any:
|
|
61
100
|
"""Gets a configuration value by key.
|
|
62
101
|
|
|
@@ -212,14 +251,59 @@ class FMUDirectoryBase:
|
|
|
212
251
|
"""
|
|
213
252
|
return self.get_file_path(relative_path).exists()
|
|
214
253
|
|
|
254
|
+
def restore(self: Self) -> None:
|
|
255
|
+
"""Attempt to reconstruct missing .fmu files from in-memory state."""
|
|
256
|
+
if not self.path.exists():
|
|
257
|
+
self.path.mkdir(parents=True, exist_ok=True)
|
|
258
|
+
logger.info("Recreated missing .fmu directory at %s", self.path)
|
|
259
|
+
|
|
260
|
+
readme_path = self.get_file_path("README")
|
|
261
|
+
if self._README_CONTENT and not readme_path.exists():
|
|
262
|
+
self.write_text_file("README", self._README_CONTENT)
|
|
263
|
+
logger.info("Restored README at %s", readme_path)
|
|
264
|
+
|
|
265
|
+
config_path = self.config.path
|
|
266
|
+
if not config_path.exists():
|
|
267
|
+
cached_model = getattr(self.config, "_cache", None)
|
|
268
|
+
if cached_model is not None:
|
|
269
|
+
self.config.save(cached_model)
|
|
270
|
+
logger.info("Restored config.json from cached model at %s", config_path)
|
|
271
|
+
else:
|
|
272
|
+
self.config.reset()
|
|
273
|
+
logger.info("Restored config.json from defaults at %s", config_path)
|
|
274
|
+
|
|
215
275
|
|
|
216
276
|
class ProjectFMUDirectory(FMUDirectoryBase):
|
|
217
|
-
|
|
277
|
+
if TYPE_CHECKING:
|
|
278
|
+
config: ProjectConfigManager
|
|
279
|
+
|
|
280
|
+
_README_CONTENT: str = PROJECT_README_CONTENT
|
|
281
|
+
|
|
282
|
+
def __init__(
|
|
283
|
+
self: Self,
|
|
284
|
+
base_path: str | Path,
|
|
285
|
+
*,
|
|
286
|
+
lock_timeout_seconds: int = DEFAULT_LOCK_TIMEOUT,
|
|
287
|
+
) -> None:
|
|
288
|
+
"""Initializes a project-based .fmu directory.
|
|
218
289
|
|
|
219
|
-
|
|
220
|
-
|
|
290
|
+
Args:
|
|
291
|
+
base_path: Project directory containing the .fmu folder.
|
|
292
|
+
lock_timeout_seconds: Lock expiration time in seconds. Default 20 minutes.
|
|
293
|
+
"""
|
|
221
294
|
self.config = ProjectConfigManager(self)
|
|
222
|
-
super().__init__(
|
|
295
|
+
super().__init__(
|
|
296
|
+
base_path,
|
|
297
|
+
CacheManager.MIN_REVISIONS,
|
|
298
|
+
lock_timeout_seconds=lock_timeout_seconds,
|
|
299
|
+
)
|
|
300
|
+
try:
|
|
301
|
+
max_revisions = self.config.get(
|
|
302
|
+
"cache_max_revisions", CacheManager.MIN_REVISIONS
|
|
303
|
+
)
|
|
304
|
+
self._cache_manager.max_revisions = max_revisions
|
|
305
|
+
except FileNotFoundError:
|
|
306
|
+
pass
|
|
223
307
|
|
|
224
308
|
def update_config(self: Self, updates: dict[str, Any]) -> ProjectConfig:
|
|
225
309
|
"""Updates multiple configuration values at once.
|
|
@@ -267,11 +351,17 @@ class ProjectFMUDirectory(FMUDirectoryBase):
|
|
|
267
351
|
return None
|
|
268
352
|
|
|
269
353
|
@classmethod
|
|
270
|
-
def find_nearest(
|
|
354
|
+
def find_nearest(
|
|
355
|
+
cls: type[Self],
|
|
356
|
+
start_path: str | Path = ".",
|
|
357
|
+
*,
|
|
358
|
+
lock_timeout_seconds: int = DEFAULT_LOCK_TIMEOUT,
|
|
359
|
+
) -> Self:
|
|
271
360
|
"""Factory method to find and open the nearest .fmu directory.
|
|
272
361
|
|
|
273
362
|
Args:
|
|
274
363
|
start_path: Path to start searching from. Default current working director
|
|
364
|
+
lock_timeout_seconds: Lock expiration time in seconds. Default 20 minutes.
|
|
275
365
|
|
|
276
366
|
Returns:
|
|
277
367
|
FMUDirectory instance
|
|
@@ -283,16 +373,146 @@ class ProjectFMUDirectory(FMUDirectoryBase):
|
|
|
283
373
|
fmu_dir_path = cls.find_fmu_directory(start_path)
|
|
284
374
|
if fmu_dir_path is None:
|
|
285
375
|
raise FileNotFoundError(f"No .fmu directory found at or above {start_path}")
|
|
286
|
-
return cls(fmu_dir_path.parent)
|
|
376
|
+
return cls(fmu_dir_path.parent, lock_timeout_seconds=lock_timeout_seconds)
|
|
377
|
+
|
|
378
|
+
def find_rms_projects(self: Self) -> list[Path]:
|
|
379
|
+
"""Searches for RMS project directories under the project root.
|
|
380
|
+
|
|
381
|
+
RMS projects are identified by the presence of both .master and rms.ini
|
|
382
|
+
files within a directory under rms/model/.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
List of Path objects to RMS project directories, sorted alphabetically.
|
|
386
|
+
Returns empty list if none found.
|
|
387
|
+
"""
|
|
388
|
+
project_root = self.base_path
|
|
389
|
+
model_root = project_root / "rms/model"
|
|
390
|
+
rms_projects: set[Path] = set()
|
|
391
|
+
|
|
392
|
+
if model_root.is_dir():
|
|
393
|
+
for candidate in model_root.iterdir():
|
|
394
|
+
if not candidate.is_dir():
|
|
395
|
+
continue
|
|
396
|
+
|
|
397
|
+
master_file = candidate / ".master"
|
|
398
|
+
ini_file = candidate / "rms.ini"
|
|
399
|
+
|
|
400
|
+
if master_file.is_file() and ini_file.is_file():
|
|
401
|
+
rms_projects.add(candidate)
|
|
402
|
+
|
|
403
|
+
return sorted(rms_projects)
|
|
404
|
+
|
|
405
|
+
def _sync_runtime_variables(self: Self) -> None:
|
|
406
|
+
"""Sync runtime variables with config file."""
|
|
407
|
+
max_revisions_from_config = self.config.load().cache_max_revisions
|
|
408
|
+
if self._cache_manager.max_revisions != max_revisions_from_config:
|
|
409
|
+
self._cache_manager.max_revisions = max_revisions_from_config
|
|
410
|
+
|
|
411
|
+
def get_dir_diff(
|
|
412
|
+
self: Self, new_fmu_dir: Self
|
|
413
|
+
) -> dict[str, list[tuple[str, Any, Any]]]:
|
|
414
|
+
"""Get the resource differences between two .fmu directories.
|
|
415
|
+
|
|
416
|
+
Compare all resources in the two .fmu directories and return a dict with the
|
|
417
|
+
diff for each resource.
|
|
418
|
+
|
|
419
|
+
Resources that are not present in both .fmu directories will not be diffed.
|
|
420
|
+
"""
|
|
421
|
+
dir_diff: dict[str, list[tuple[str, Any, Any]]] = {}
|
|
422
|
+
|
|
423
|
+
for resource in vars(self):
|
|
424
|
+
try:
|
|
425
|
+
match resource:
|
|
426
|
+
case "config":
|
|
427
|
+
current_resource: ProjectConfigManager = getattr(self, resource)
|
|
428
|
+
new_resource: ProjectConfigManager = getattr(
|
|
429
|
+
new_fmu_dir, resource
|
|
430
|
+
)
|
|
431
|
+
changes = current_resource.get_resource_diff(new_resource)
|
|
432
|
+
case "_changelog":
|
|
433
|
+
current_changelog: ChangelogManager = getattr(self, resource)
|
|
434
|
+
new_changelog: ChangelogManager = getattr(new_fmu_dir, resource)
|
|
435
|
+
changelog_diff = current_changelog.get_changelog_diff(
|
|
436
|
+
new_changelog
|
|
437
|
+
)
|
|
438
|
+
if len(changelog_diff.root) > 0:
|
|
439
|
+
changes = [("changelog", None, changelog_diff)]
|
|
440
|
+
else:
|
|
441
|
+
changes = []
|
|
442
|
+
|
|
443
|
+
case _:
|
|
444
|
+
continue
|
|
445
|
+
except AttributeError:
|
|
446
|
+
continue
|
|
447
|
+
except FileNotFoundError:
|
|
448
|
+
continue
|
|
449
|
+
|
|
450
|
+
dir_diff[resource] = changes
|
|
451
|
+
return dir_diff
|
|
452
|
+
|
|
453
|
+
def sync_dir(self: Self, new_fmu_dir: Self) -> dict[str, Any]:
|
|
454
|
+
"""Sync the resources in two .fmu directories.
|
|
455
|
+
|
|
456
|
+
Compare all resources in the two .fmu directories and merge all changes
|
|
457
|
+
in the new .fmu directory into the current .fmu directory.
|
|
458
|
+
|
|
459
|
+
Resources that are not present in both .fmu directories will not be synced.
|
|
460
|
+
"""
|
|
461
|
+
changes_in_dir = self.get_dir_diff(new_fmu_dir)
|
|
462
|
+
updates: dict[str, Any] = {}
|
|
463
|
+
for resource, changes in changes_in_dir.items():
|
|
464
|
+
if len(changes) == 0:
|
|
465
|
+
continue
|
|
466
|
+
|
|
467
|
+
updated_resource: Any
|
|
468
|
+
if resource == "config":
|
|
469
|
+
current_config: ProjectConfigManager = getattr(self, resource)
|
|
470
|
+
updated_resource = current_config.merge_changes(changes)
|
|
471
|
+
self._sync_runtime_variables()
|
|
472
|
+
elif resource == "_changelog":
|
|
473
|
+
current_changelog: ChangelogManager = getattr(self, resource)
|
|
474
|
+
updated_resource = current_changelog.merge_changes(changes[0][2].root)
|
|
475
|
+
|
|
476
|
+
updates[resource] = updated_resource
|
|
477
|
+
|
|
478
|
+
self._changelog.log_merge_to_changelog(
|
|
479
|
+
source_path=self.path,
|
|
480
|
+
incoming_path=new_fmu_dir.path,
|
|
481
|
+
merged_resources=list(updates.keys()),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
return updates
|
|
287
485
|
|
|
288
486
|
|
|
289
487
|
class UserFMUDirectory(FMUDirectoryBase):
|
|
290
|
-
|
|
488
|
+
if TYPE_CHECKING:
|
|
489
|
+
config: UserConfigManager
|
|
291
490
|
|
|
292
|
-
|
|
293
|
-
|
|
491
|
+
_README_CONTENT: str = USER_README_CONTENT
|
|
492
|
+
|
|
493
|
+
def __init__(
|
|
494
|
+
self: Self,
|
|
495
|
+
*,
|
|
496
|
+
lock_timeout_seconds: int = DEFAULT_LOCK_TIMEOUT,
|
|
497
|
+
) -> None:
|
|
498
|
+
"""Initializes a user .fmu directory.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
lock_timeout_seconds: Lock expiration time in seconds. Default 20 minutes.
|
|
502
|
+
"""
|
|
294
503
|
self.config = UserConfigManager(self)
|
|
295
|
-
super().__init__(
|
|
504
|
+
super().__init__(
|
|
505
|
+
Path.home(),
|
|
506
|
+
CacheManager.MIN_REVISIONS,
|
|
507
|
+
lock_timeout_seconds=lock_timeout_seconds,
|
|
508
|
+
)
|
|
509
|
+
try:
|
|
510
|
+
max_revisions = self.config.get(
|
|
511
|
+
"cache_max_revisions", CacheManager.MIN_REVISIONS
|
|
512
|
+
)
|
|
513
|
+
self._cache_manager.max_revisions = max_revisions
|
|
514
|
+
except FileNotFoundError:
|
|
515
|
+
pass
|
|
296
516
|
|
|
297
517
|
def update_config(self: Self, updates: dict[str, Any]) -> UserConfig:
|
|
298
518
|
"""Updates multiple configuration values at once.
|
|
@@ -310,12 +530,17 @@ class UserFMUDirectory(FMUDirectoryBase):
|
|
|
310
530
|
return cast("UserConfig", super().update_config(updates))
|
|
311
531
|
|
|
312
532
|
|
|
313
|
-
def get_fmu_directory(
|
|
533
|
+
def get_fmu_directory(
|
|
534
|
+
base_path: str | Path,
|
|
535
|
+
*,
|
|
536
|
+
lock_timeout_seconds: int = DEFAULT_LOCK_TIMEOUT,
|
|
537
|
+
) -> ProjectFMUDirectory:
|
|
314
538
|
"""Initializes access to a .fmu directory.
|
|
315
539
|
|
|
316
540
|
Args:
|
|
317
541
|
base_path: The directory containing the .fmu directory or one of its parent
|
|
318
542
|
dirs
|
|
543
|
+
lock_timeout_seconds: Lock expiration time in seconds. Default 20 minutes.
|
|
319
544
|
|
|
320
545
|
Returns:
|
|
321
546
|
FMUDirectory instance
|
|
@@ -326,14 +551,19 @@ def get_fmu_directory(base_path: str | Path) -> ProjectFMUDirectory:
|
|
|
326
551
|
PermissionError: If lacking permissions to read/write to the directory
|
|
327
552
|
|
|
328
553
|
"""
|
|
329
|
-
return ProjectFMUDirectory(base_path)
|
|
554
|
+
return ProjectFMUDirectory(base_path, lock_timeout_seconds=lock_timeout_seconds)
|
|
330
555
|
|
|
331
556
|
|
|
332
|
-
def find_nearest_fmu_directory(
|
|
557
|
+
def find_nearest_fmu_directory(
|
|
558
|
+
start_path: str | Path = ".",
|
|
559
|
+
*,
|
|
560
|
+
lock_timeout_seconds: int = DEFAULT_LOCK_TIMEOUT,
|
|
561
|
+
) -> ProjectFMUDirectory:
|
|
333
562
|
"""Factory method to find and open the nearest .fmu directory.
|
|
334
563
|
|
|
335
564
|
Args:
|
|
336
565
|
start_path: Path to start searching from. Default current working directory
|
|
566
|
+
lock_timeout_seconds: Lock expiration time in seconds. Default 20 minutes.
|
|
337
567
|
|
|
338
568
|
Returns:
|
|
339
569
|
FMUDirectory instance
|
|
@@ -341,4 +571,6 @@ def find_nearest_fmu_directory(start_path: str | Path = ".") -> ProjectFMUDirect
|
|
|
341
571
|
Raises:
|
|
342
572
|
FileNotFoundError: If no .fmu directory is found
|
|
343
573
|
"""
|
|
344
|
-
return ProjectFMUDirectory.find_nearest(
|
|
574
|
+
return ProjectFMUDirectory.find_nearest(
|
|
575
|
+
start_path, lock_timeout_seconds=lock_timeout_seconds
|
|
576
|
+
)
|
fmu/settings/_init.py
CHANGED
|
@@ -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,185 @@
|
|
|
1
|
+
"""Utilities for storing revision snapshots of .fmu files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
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
|
+
RETENTION_DAYS: ClassVar[int] = 30
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self: Self,
|
|
33
|
+
fmu_dir: FMUDirectoryBase,
|
|
34
|
+
max_revisions: int = 5,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Initialize the cache manager.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
fmu_dir: The FMUDirectory instance.
|
|
40
|
+
max_revisions: Maximum number of revisions to retain. Default is 5.
|
|
41
|
+
Values below 5 are set to 5.
|
|
42
|
+
"""
|
|
43
|
+
self._fmu_dir = fmu_dir
|
|
44
|
+
self._cache_root = Path("cache")
|
|
45
|
+
self._max_revisions = max(self.MIN_REVISIONS, max_revisions)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def max_revisions(self: Self) -> int:
|
|
49
|
+
"""Maximum number of revisions retained per resource."""
|
|
50
|
+
return self._max_revisions
|
|
51
|
+
|
|
52
|
+
@max_revisions.setter
|
|
53
|
+
def max_revisions(self: Self, value: int) -> None:
|
|
54
|
+
"""Update the per-resource revision retention.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
value: The new maximum number of revisions. Minimum value is 5.
|
|
58
|
+
Values below 5 are set to 5.
|
|
59
|
+
"""
|
|
60
|
+
self._max_revisions = max(self.MIN_REVISIONS, value)
|
|
61
|
+
|
|
62
|
+
def store_revision(
|
|
63
|
+
self: Self,
|
|
64
|
+
resource_file_path: Path | str,
|
|
65
|
+
content: str,
|
|
66
|
+
encoding: str = "utf-8",
|
|
67
|
+
skip_trim: bool = False,
|
|
68
|
+
) -> Path | None:
|
|
69
|
+
"""Write a full snapshot of the resource file to the cache directory.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
resource_file_path: Relative path within the ``.fmu`` directory (e.g.,
|
|
73
|
+
``config.json``) of the resource file being cached.
|
|
74
|
+
content: Serialized payload to store.
|
|
75
|
+
encoding: Encoding used when persisting the snapshot. Defaults to UTF-8.
|
|
76
|
+
skip_trim: If True, skip count-based trimming. Default is False.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Absolute filesystem path to the stored snapshot.
|
|
80
|
+
"""
|
|
81
|
+
resource_file_path = Path(resource_file_path)
|
|
82
|
+
cache_dir = self._ensure_resource_cache_dir(resource_file_path)
|
|
83
|
+
snapshot_name = self._snapshot_filename(resource_file_path)
|
|
84
|
+
snapshot_path = cache_dir / snapshot_name
|
|
85
|
+
|
|
86
|
+
cache_relative = self._cache_root / resource_file_path.stem
|
|
87
|
+
self._fmu_dir.write_text_file(
|
|
88
|
+
cache_relative / snapshot_name, content, encoding=encoding
|
|
89
|
+
)
|
|
90
|
+
logger.debug("Stored revision snapshot at %s", snapshot_path)
|
|
91
|
+
|
|
92
|
+
if not skip_trim:
|
|
93
|
+
self._trim(cache_dir)
|
|
94
|
+
return snapshot_path
|
|
95
|
+
|
|
96
|
+
def list_revisions(self: Self, resource_file_path: Path | str) -> list[Path]:
|
|
97
|
+
"""List existing snapshots for a resource file, sorted oldest to newest.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
resource_file_path: Relative path within the ``.fmu`` directory (e.g.,
|
|
101
|
+
``config.json``) whose cache entries should be listed.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
A list of absolute `Path` objects sorted oldest to newest.
|
|
105
|
+
"""
|
|
106
|
+
resource_file_path = Path(resource_file_path)
|
|
107
|
+
cache_relative = self._cache_root / resource_file_path.stem
|
|
108
|
+
if not self._fmu_dir.file_exists(cache_relative):
|
|
109
|
+
return []
|
|
110
|
+
cache_dir = self._fmu_dir.get_file_path(cache_relative)
|
|
111
|
+
|
|
112
|
+
revisions = [p for p in cache_dir.iterdir() if p.is_file()]
|
|
113
|
+
revisions.sort(key=lambda path: path.name)
|
|
114
|
+
return revisions
|
|
115
|
+
|
|
116
|
+
def _ensure_resource_cache_dir(self: Self, resource_file_path: Path) -> Path:
|
|
117
|
+
"""Create (if needed) and return the cache directory for resource file."""
|
|
118
|
+
self._cache_root_path(create=True)
|
|
119
|
+
resource_cache_dir_relative = self._cache_root / resource_file_path.stem
|
|
120
|
+
return self._fmu_dir.ensure_directory(resource_cache_dir_relative)
|
|
121
|
+
|
|
122
|
+
def _cache_root_path(self: Self, create: bool) -> Path:
|
|
123
|
+
"""Resolve the cache root, creating it and the cachedir tag if requested."""
|
|
124
|
+
if create:
|
|
125
|
+
cache_root = self._fmu_dir.ensure_directory(self._cache_root)
|
|
126
|
+
self._ensure_cachedir_tag()
|
|
127
|
+
return cache_root
|
|
128
|
+
|
|
129
|
+
return self._fmu_dir.get_file_path(self._cache_root)
|
|
130
|
+
|
|
131
|
+
def _ensure_cachedir_tag(self: Self) -> None:
|
|
132
|
+
"""Ensure the cache root complies with the Cachedir specification."""
|
|
133
|
+
tag_path_relative = self._cache_root / "CACHEDIR.TAG"
|
|
134
|
+
if self._fmu_dir.file_exists(tag_path_relative):
|
|
135
|
+
return
|
|
136
|
+
self._fmu_dir.write_text_file(tag_path_relative, _CACHEDIR_TAG_CONTENT)
|
|
137
|
+
|
|
138
|
+
def _snapshot_filename(self: Self, resource_file_path: Path) -> str:
|
|
139
|
+
"""Generate a timestamped filename for the next snapshot."""
|
|
140
|
+
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ")
|
|
141
|
+
suffix = resource_file_path.suffix or ".txt"
|
|
142
|
+
token = uuid4().hex[:8]
|
|
143
|
+
return f"{timestamp}-{token}{suffix}"
|
|
144
|
+
|
|
145
|
+
def _trim(self: Self, cache_dir: Path) -> None:
|
|
146
|
+
"""Remove the oldest snapshots until the retention limit is respected."""
|
|
147
|
+
revisions = [p for p in cache_dir.iterdir() if p.is_file()]
|
|
148
|
+
if len(revisions) <= self.max_revisions:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
revisions.sort(key=lambda path: path.name)
|
|
152
|
+
excess = len(revisions) - self.max_revisions
|
|
153
|
+
for old_revision in revisions[:excess]:
|
|
154
|
+
try:
|
|
155
|
+
old_revision.unlink()
|
|
156
|
+
except FileNotFoundError:
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
def trim_by_age(
|
|
160
|
+
self: Self, resource_file_path: Path | str, retention_days: int | None = None
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Remove snapshots older than retention_days.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
resource_file_path: Relative path within the ``.fmu`` directory (e.g.,
|
|
166
|
+
``logs/user_session_log.json``) whose cache entries should be trimmed.
|
|
167
|
+
retention_days: Maximum age in days to retain snapshots.
|
|
168
|
+
If None, uses CacheManager.RETENTION_DAYS (default: 30 days).
|
|
169
|
+
"""
|
|
170
|
+
if retention_days is None:
|
|
171
|
+
retention_days = self.RETENTION_DAYS
|
|
172
|
+
revisions = self.list_revisions(resource_file_path)
|
|
173
|
+
cutoff = datetime.now(UTC) - timedelta(days=retention_days)
|
|
174
|
+
|
|
175
|
+
for revision in revisions:
|
|
176
|
+
try:
|
|
177
|
+
mtime_timestamp = revision.stat().st_mtime
|
|
178
|
+
file_time = datetime.fromtimestamp(mtime_timestamp, tz=UTC)
|
|
179
|
+
except (OSError, ValueError):
|
|
180
|
+
logger.warning("Skipping file with unreadable mtime: %s", revision.name)
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
if file_time < cutoff:
|
|
184
|
+
revision.unlink()
|
|
185
|
+
logger.debug("Deleted old revision: %s", revision)
|