fmu-settings 0.5.3__py3-none-any.whl → 0.5.4__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.

Potentially problematic release.


This version of fmu-settings might be problematic. Click here for more details.

fmu/settings/_fmu_dir.py CHANGED
@@ -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
- config: ProjectConfigManager
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
- config: UserConfigManager
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._safe_load()
91
+ lock_info = self.safe_load()
90
92
  if lock_info:
91
93
  raise LockError(
92
94
  f"Lock file is held by {lock_info.user}@{lock_info.hostname} "
@@ -156,12 +158,16 @@ class LockManager(PydanticResourceManager[LockInfo]):
156
158
  with contextlib.suppress(OSError):
157
159
  temp_path.unlink()
158
160
 
159
- def is_locked(self: Self) -> bool:
161
+ def is_locked(self: Self, *, propagate_errors: bool = False) -> bool:
160
162
  """Returns whether or not the lock is locked by anyone.
161
163
 
162
164
  This does a force load on the lock file.
163
165
  """
164
- lock_info = self._safe_load(force=True)
166
+ lock_info = (
167
+ self.load(force=True, store_cache=False)
168
+ if propagate_errors
169
+ else self.safe_load(force=True, store_cache=False)
170
+ )
165
171
  if not lock_info:
166
172
  return False
167
173
  return time.time() < lock_info.expires_at
@@ -170,15 +176,16 @@ class LockManager(PydanticResourceManager[LockInfo]):
170
176
  """Returns whether or not the lock is currently acquired by this instance."""
171
177
  if self._cache is None or self._acquired_at is None:
172
178
  return False
173
- return self._is_mine(self._cache) and not self._is_stale()
179
+
180
+ current_lock = self.safe_load(force=True, store_cache=False)
181
+ if current_lock is None:
182
+ return False
183
+
184
+ return self._is_mine(current_lock) and not self._is_stale()
174
185
 
175
186
  def ensure_can_write(self: Self) -> None:
176
187
  """Raise PermissionError if another process currently holds the lock."""
177
- try:
178
- lock_info = self.load(force=True, store_cache=False)
179
- except Exception:
180
- lock_info = None
181
-
188
+ lock_info = self.safe_load(force=True, store_cache=False)
182
189
  if (
183
190
  self.exists
184
191
  and lock_info is not None
@@ -202,7 +209,7 @@ class LockManager(PydanticResourceManager[LockInfo]):
202
209
  self.release()
203
210
  raise LockNotFoundError("Cannot refresh: lock file does not exist")
204
211
 
205
- lock_info = self._safe_load()
212
+ lock_info = self.safe_load()
206
213
  if not lock_info or not self._is_mine(lock_info):
207
214
  raise LockError(
208
215
  "Cannot refresh: lock file is held by another process or host."
@@ -214,7 +221,7 @@ class LockManager(PydanticResourceManager[LockInfo]):
214
221
  def release(self: Self) -> None:
215
222
  """Release the lock."""
216
223
  if self.exists:
217
- lock_info = self._safe_load()
224
+ lock_info = self.safe_load()
218
225
  if lock_info and self._is_mine(lock_info):
219
226
  with contextlib.suppress(ValueError):
220
227
  self.path.unlink()
@@ -222,12 +229,15 @@ class LockManager(PydanticResourceManager[LockInfo]):
222
229
  self._acquired_at = None
223
230
  self._cache = None
224
231
 
225
- def save(self: Self, data: LockInfo) -> None:
232
+ def save(
233
+ self: Self,
234
+ data: LockInfo,
235
+ ) -> None:
226
236
  """Save the lockfile in an NFS-atomic manner.
227
237
 
228
238
  This overrides save() from the Pydantic resource manager.
229
239
  """
230
- lock_info = self._safe_load()
240
+ lock_info = self.safe_load()
231
241
  if not lock_info or not self._is_mine(lock_info):
232
242
  raise LockError(
233
243
  "Failed to save lock: lock file is held by another process or host."
@@ -254,20 +264,22 @@ class LockManager(PydanticResourceManager[LockInfo]):
254
264
  and lock_info.acquired_at == self._acquired_at
255
265
  )
256
266
 
257
- def _safe_load(self: Self, force: bool = False) -> LockInfo | None:
267
+ def safe_load(
268
+ self: Self, force: bool = False, store_cache: bool = False
269
+ ) -> LockInfo | None:
258
270
  """Load lock info, returning None if corrupted.
259
271
 
260
272
  Because this file does not exist in a static state, wrap around loading it.
261
273
  """
262
274
  try:
263
- return self.load(force=force)
275
+ return self.load(force=force, store_cache=store_cache)
264
276
  except Exception:
265
277
  return None
266
278
 
267
279
  def _is_stale(self: Self, lock_info: LockInfo | None = None) -> bool:
268
280
  """Check if existing lock is stale (expired or process dead)."""
269
281
  if lock_info is None:
270
- lock_info = self._safe_load()
282
+ lock_info = self.safe_load()
271
283
 
272
284
  if not lock_info:
273
285
  return True
@@ -22,6 +22,8 @@ MutablePydanticResource = TypeVar("MutablePydanticResource", bound=ResettableBas
22
22
  class PydanticResourceManager(Generic[PydanticResource]):
23
23
  """Base class for managing resources represented by Pydantic models."""
24
24
 
25
+ cache_enabled: bool = True
26
+
25
27
  def __init__(
26
28
  self: Self, fmu_dir: FMUDirectoryBase, model_class: type[PydanticResource]
27
29
  ) -> None:
@@ -99,15 +101,22 @@ class PydanticResourceManager(Generic[PydanticResource]):
99
101
 
100
102
  return self._cache
101
103
 
102
- def save(self: Self, model: PydanticResource) -> None:
104
+ def save(
105
+ self: Self,
106
+ model: PydanticResource,
107
+ ) -> None:
103
108
  """Save the Pydantic model to disk.
104
109
 
105
110
  Args:
106
- model: Validated Pydantic model instance
111
+ model: Validated Pydantic model instance.
107
112
  """
108
113
  self.fmu_dir._lock.ensure_can_write()
109
114
  json_data = model.model_dump_json(by_alias=True, indent=2)
110
115
  self.fmu_dir.write_text_file(self.relative_path, json_data)
116
+
117
+ if self.cache_enabled and self.exists:
118
+ self.fmu_dir.cache.store_revision(self.relative_path, json_data)
119
+
111
120
  self._cache = model
112
121
 
113
122
 
fmu/settings/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.5.3'
32
- __version_tuple__ = version_tuple = (0, 5, 3)
31
+ __version__ = version = '0.5.4'
32
+ __version_tuple__ = version_tuple = (0, 5, 4)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fmu-settings
3
- Version: 0.5.3
3
+ Version: 0.5.4
4
4
  Summary: A library for managing FMU settings
5
5
  Author-email: Equinor <fg-fmu_atlas@equinor.com>
6
6
  License: GPL-3.0
@@ -1,24 +1,25 @@
1
1
  fmu/__init__.py,sha256=htx6HlMme77I6pZ8U256-2B2cMJuELsu3JN3YM2Efh4,144
2
2
  fmu/settings/__init__.py,sha256=CkEE7al_uBCQO1lxBKN5LzyCwzzH5Aq6kkEIR7f-zTw,336
3
- fmu/settings/_fmu_dir.py,sha256=XeZjec78q0IUOpBq-VMkKoWtzXwBeQi2qWRIh_SIFwU,10859
3
+ fmu/settings/_fmu_dir.py,sha256=J0K6CWlkk3Z5ojyOZaUDYQadx0_FiCyKNn1ZGs79h1o,11592
4
4
  fmu/settings/_global_config.py,sha256=C0_o99OhOc49ynz4h6ygbbHHH8OOI5lcVFr-9FCwD0c,9331
5
5
  fmu/settings/_init.py,sha256=ucueS0BlEsM3MkX7IaRISloH4vF7-_ZKSphrORbHgJ4,4381
6
6
  fmu/settings/_logging.py,sha256=nEdmZlNCBsB1GfDmFMKCjZmeuRp3CRlbz1EYUemc95Y,1104
7
- fmu/settings/_version.py,sha256=EWl7XaGZUG57Di8WiRltpKAkwy1CShJuJ-i6_rAPr-w,704
7
+ fmu/settings/_version.py,sha256=OrfVZdCDQ-QC6dUnxdROooJjwvLfeDMedTBstpAdSBU,704
8
8
  fmu/settings/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  fmu/settings/types.py,sha256=aeXEsznBTT1YRRY_LSRqK1j2gmMmyLYYTGYl3a9fweU,513
10
10
  fmu/settings/_resources/__init__.py,sha256=LHYR_F7lNGdv8N6R3cEwds5CJQpkOthXFqsEs24vgF8,118
11
+ fmu/settings/_resources/cache_manager.py,sha256=TCVefkQEZuEdrzS0yOs7K-fCMouU4fctRtEvj4Du28M,5656
11
12
  fmu/settings/_resources/config_managers.py,sha256=QcCLlSw8KdJKrkhGax5teFJzjgQG3ym7Ljs1DykjFbc,1570
12
- fmu/settings/_resources/lock_manager.py,sha256=1LL4FUBwvIIzBz5YXC0eoqdGUizVN5ZKwx_8CbwTudU,10163
13
- fmu/settings/_resources/pydantic_resource_manager.py,sha256=BUWO6IHSoT0Ma6QgseweEf7uiGeMwHBEoCyGYPYYFdA,9290
13
+ fmu/settings/_resources/lock_manager.py,sha256=3h1wRhDvn1xYkKoPs4I1e-6PO8Ctu8StdS7Nh5DW5O4,10508
14
+ fmu/settings/_resources/pydantic_resource_manager.py,sha256=iCLO0xzTPhcYyhCoafKf7DAxjIDv8VBQIqRl-HYEDok,9472
14
15
  fmu/settings/models/__init__.py,sha256=lRlXgl55ba2upmDzdvzx8N30JMq2Osnm8aa_xxTZn8A,112
15
16
  fmu/settings/models/_enums.py,sha256=SQUZ-2mQcTx4F0oefPFfuQzMKsKTSFSB-wq_CH7TBRE,734
16
17
  fmu/settings/models/_mappings.py,sha256=Z4Ex7MtmajBr6FjaNzmwDRwtJlaZZ8YKh9NDmZHRKPI,2832
17
18
  fmu/settings/models/lock_info.py,sha256=-oHDF9v9bDLCoFvEg4S6XXYLeo19zRAZ8HynCv75VWg,711
18
19
  fmu/settings/models/project_config.py,sha256=pxb54JmpXNMVAFUu_yJ89dNrYEk6hrPuFfFUpf84Jh0,1099
19
20
  fmu/settings/models/user_config.py,sha256=dWFTcZY6UnEgNTuGqB-izraJ657PecsW0e0Nt9GBDhI,2666
20
- fmu_settings-0.5.3.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
21
- fmu_settings-0.5.3.dist-info/METADATA,sha256=MA3zZ6GPHQ0bJtHnAcCINEuDnaJtDRHT0E0SrmGu-cg,2116
22
- fmu_settings-0.5.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
23
- fmu_settings-0.5.3.dist-info/top_level.txt,sha256=Z-FIY3pxn0UK2Wxi9IJ7fKoLSraaxuNGi1eokiE0ShM,4
24
- fmu_settings-0.5.3.dist-info/RECORD,,
21
+ fmu_settings-0.5.4.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
22
+ fmu_settings-0.5.4.dist-info/METADATA,sha256=kysnXaWPfT7_e-lqxRIEOgbdirMo-VvzbcdNeFztyzA,2116
23
+ fmu_settings-0.5.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
24
+ fmu_settings-0.5.4.dist-info/top_level.txt,sha256=Z-FIY3pxn0UK2Wxi9IJ7fKoLSraaxuNGi1eokiE0ShM,4
25
+ fmu_settings-0.5.4.dist-info/RECORD,,