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 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
- def __init__(self: Self, base_path: str | Path) -> None:
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
- config: ProjectConfigManager
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
- def __init__(self, base_path: str | Path) -> None:
220
- """Initializes a project-based .fmu directory."""
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__(base_path)
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(cls: type[Self], start_path: str | Path = ".") -> Self:
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
- config: UserConfigManager
488
+ if TYPE_CHECKING:
489
+ config: UserConfigManager
291
490
 
292
- def __init__(self) -> None:
293
- """Initializes a project-based .fmu directory."""
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__(Path.home())
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(base_path: str | Path) -> ProjectFMUDirectory:
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(start_path: str | Path = ".") -> ProjectFMUDirectory:
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(start_path)
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(base_path)
102
- fmu_dir.write_text_file("README", _README)
79
+ fmu_dir = ProjectFMUDirectory(
80
+ base_path,
81
+ lock_timeout_seconds=lock_timeout_seconds,
82
+ )
83
+ fmu_dir.write_text_file("README", PROJECT_README_CONTENT)
103
84
 
104
85
  fmu_dir.config.reset()
105
86
  if config_data:
@@ -115,9 +96,15 @@ def init_fmu_directory(
115
96
  return fmu_dir
116
97
 
117
98
 
118
- def init_user_fmu_directory() -> UserFMUDirectory:
99
+ def init_user_fmu_directory(
100
+ *,
101
+ lock_timeout_seconds: int = DEFAULT_LOCK_TIMEOUT,
102
+ ) -> UserFMUDirectory:
119
103
  """Creates and initializes a user's $HOME/.fmu directory.
120
104
 
105
+ Args:
106
+ lock_timeout_seconds: Lock expiration time in seconds. Default 20 minutes.
107
+
121
108
  Returns:
122
109
  Instance of FMUDirectory
123
110
 
@@ -131,8 +118,8 @@ def init_user_fmu_directory() -> UserFMUDirectory:
131
118
 
132
119
  _create_fmu_directory(Path.home())
133
120
 
134
- fmu_dir = UserFMUDirectory()
135
- fmu_dir.write_text_file("README", _USER_README)
121
+ fmu_dir = UserFMUDirectory(lock_timeout_seconds=lock_timeout_seconds)
122
+ fmu_dir.write_text_file("README", USER_README_CONTENT)
136
123
 
137
124
  fmu_dir.config.reset()
138
125
  logger.debug(f"Successfully initialized .fmu directory at '{fmu_dir}'")
@@ -0,0 +1,34 @@
1
+ """Shared README content for .fmu directories."""
2
+
3
+ from textwrap import dedent
4
+ from typing import Final
5
+
6
+ PROJECT_README_CONTENT: Final[str] = dedent(
7
+ """\
8
+ This directory contains static configuration data for your FMU project.
9
+
10
+ You should *not* manually modify files within this directory. Doing so may
11
+ result in erroneous behavior or erroneous data in your FMU project.
12
+
13
+ Changes to data stored within this directory must happen through the FMU
14
+ Settings application.
15
+
16
+ Run `fmu settings` to do this.
17
+ """
18
+ )
19
+
20
+ USER_README_CONTENT: Final[str] = dedent(
21
+ """\
22
+ This directory contains static data and configuration elements used by some
23
+ components in FMU. It may also contains sensitive access tokens that should not be
24
+ shared with others.
25
+
26
+ You should *not* manually modify files within this directory. Doing so may
27
+ result in erroneous behavior by some FMU components.
28
+
29
+ Changes to data stored within this directory must happen through the FMU
30
+ Settings application.
31
+
32
+ Run `fmu settings` to do this.
33
+ """
34
+ )
@@ -0,0 +1,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)