fmu-settings 0.2.0__tar.gz → 0.3.1__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.

Files changed (43) hide show
  1. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/PKG-INFO +1 -1
  2. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu/settings/__init__.py +1 -1
  3. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu/settings/_fmu_dir.py +3 -0
  4. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu/settings/_resources/config_managers.py +1 -2
  5. fmu_settings-0.3.1/src/fmu/settings/_resources/lock_manager.py +281 -0
  6. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu/settings/_resources/pydantic_resource_manager.py +2 -2
  7. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu/settings/_version.py +3 -3
  8. fmu_settings-0.3.1/src/fmu/settings/models/lock_info.py +30 -0
  9. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu/settings/models/project_config.py +1 -1
  10. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu/settings/models/user_config.py +1 -2
  11. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu_settings.egg-info/PKG-INFO +1 -1
  12. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu_settings.egg-info/SOURCES.txt +3 -0
  13. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/tests/conftest.py +1 -1
  14. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/tests/test_fmu_dir.py +20 -0
  15. fmu_settings-0.3.1/tests/test_resources/test_lock_manager.py +585 -0
  16. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/.coveragerc +0 -0
  17. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/.github/pull_request_template.md +0 -0
  18. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/.github/workflows/ci.yml +0 -0
  19. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/.github/workflows/codeql.yml +0 -0
  20. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/.github/workflows/publish.yml +0 -0
  21. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/.gitignore +0 -0
  22. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/CONTRIBUTING.md +0 -0
  23. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/LICENSE +0 -0
  24. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/README.md +0 -0
  25. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/SECURITY.md +0 -0
  26. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/pyproject.toml +0 -0
  27. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/setup.cfg +0 -0
  28. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu/__init__.py +0 -0
  29. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu/settings/_init.py +0 -0
  30. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu/settings/_logging.py +0 -0
  31. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu/settings/_resources/__init__.py +0 -0
  32. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu/settings/models/__init__.py +0 -0
  33. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu/settings/models/_enums.py +0 -0
  34. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu/settings/models/_mappings.py +0 -0
  35. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu/settings/py.typed +0 -0
  36. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu/settings/types.py +0 -0
  37. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu_settings.egg-info/dependency_links.txt +0 -0
  38. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu_settings.egg-info/requires.txt +0 -0
  39. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/src/fmu_settings.egg-info/top_level.txt +0 -0
  40. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/tests/test_init.py +0 -0
  41. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/tests/test_resources/test_project_config.py +1 -1
  42. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/tests/test_resources/test_resource_managers.py +0 -0
  43. {fmu_settings-0.2.0 → fmu_settings-0.3.1}/tests/test_resources/test_user_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fmu-settings
3
- Version: 0.2.0
3
+ Version: 0.3.1
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
@@ -3,7 +3,7 @@
3
3
  try:
4
4
  from ._version import version
5
5
 
6
- __version__ = version
6
+ __version__: str = version
7
7
  except ImportError:
8
8
  __version__ = version = "0.0.0"
9
9
 
@@ -8,6 +8,7 @@ from ._resources.config_managers import (
8
8
  ProjectConfigManager,
9
9
  UserConfigManager,
10
10
  )
11
+ from ._resources.lock_manager import LockManager
11
12
  from .models.project_config import ProjectConfig
12
13
  from .models.user_config import UserConfig
13
14
 
@@ -20,6 +21,7 @@ class FMUDirectoryBase:
20
21
  """Provides access to a .fmu directory and operations on its contents."""
21
22
 
22
23
  config: FMUConfigManager
24
+ _lock: LockManager
23
25
 
24
26
  def __init__(self: Self, base_path: str | Path) -> None:
25
27
  """Initializes access to a .fmu directory.
@@ -35,6 +37,7 @@ class FMUDirectoryBase:
35
37
  """
36
38
  self.base_path = Path(base_path).resolve()
37
39
  logger.debug(f"Initializing FMUDirectory from '{base_path}'")
40
+ self._lock = LockManager(self)
38
41
 
39
42
  fmu_dir = self.base_path / ".fmu"
40
43
  if fmu_dir.exists():
@@ -4,14 +4,13 @@ from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
6
  from typing import TYPE_CHECKING, Any, Final, Self, TypeVar
7
- from uuid import UUID # noqa TC003
8
7
 
9
8
  from pydantic import ValidationError
10
9
 
11
10
  from fmu.settings._logging import null_logger
12
11
  from fmu.settings.models.project_config import ProjectConfig
13
12
  from fmu.settings.models.user_config import UserConfig
14
- from fmu.settings.types import ResettableBaseModel, VersionStr # noqa TC001
13
+ from fmu.settings.types import ResettableBaseModel # noqa: TC001
15
14
 
16
15
  from .pydantic_resource_manager import PydanticResourceManager
17
16
 
@@ -0,0 +1,281 @@
1
+ """Manages a .lock file in a .fmu/ directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import socket
8
+ import time
9
+ import uuid
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Final, Literal, Self
12
+
13
+ from fmu.settings._logging import null_logger
14
+ from fmu.settings.models.lock_info import LockInfo
15
+
16
+ from .pydantic_resource_manager import PydanticResourceManager
17
+
18
+ if TYPE_CHECKING:
19
+ from types import TracebackType
20
+
21
+ # Avoid circular dependency for type hint in __init__ only
22
+ from fmu.settings._fmu_dir import (
23
+ FMUDirectoryBase,
24
+ )
25
+
26
+ logger: Final = null_logger(__name__)
27
+
28
+ DEFAULT_LOCK_TIMEOUT: Final[int] = 1200 # 20 minutes
29
+
30
+
31
+ class LockError(Exception):
32
+ """Raised when the lock cannot be acquired."""
33
+
34
+
35
+ class LockManager(PydanticResourceManager[LockInfo]):
36
+ """Manages the .lock file."""
37
+
38
+ def __init__(
39
+ self: Self,
40
+ fmu_dir: FMUDirectoryBase,
41
+ timeout_seconds: int = DEFAULT_LOCK_TIMEOUT,
42
+ ) -> None:
43
+ """Initializes the lock manager.
44
+
45
+ Args:
46
+ fmu_dir: The FMUDirectory instance
47
+ timeout_seconds: Lock expiration time in seconds (default 20 minutes)
48
+ """
49
+ super().__init__(fmu_dir, LockInfo)
50
+ self._timeout_seconds = timeout_seconds
51
+ self._acquired_at: float | None = None
52
+
53
+ @property
54
+ def relative_path(self: Self) -> Path:
55
+ """Returns the relative path to the .lock file."""
56
+ return Path(".lock")
57
+
58
+ def acquire(self: Self, wait: bool = False, wait_timeout: float = 5.0) -> None:
59
+ """Acquire the lock.
60
+
61
+ Args:
62
+ wait: If true, wait for lock to become available
63
+ wait_timeout: Maximum time to wait in seconds
64
+
65
+ Raises:
66
+ LockError: If lock cannot be acquired
67
+ """
68
+ if self._acquired_at is not None:
69
+ raise LockError("Lock already acquired")
70
+
71
+ if wait and wait_timeout <= 0:
72
+ raise ValueError("wait_timeout must be positive")
73
+
74
+ start_time = time.time()
75
+
76
+ while True:
77
+ if self.exists and self._is_stale():
78
+ with contextlib.suppress(OSError):
79
+ self.path.unlink()
80
+
81
+ if self._try_acquire():
82
+ return
83
+
84
+ if not wait:
85
+ lock_info = self._safe_load()
86
+ if lock_info:
87
+ raise LockError(
88
+ f"Lock file is held by {lock_info.user}@{lock_info.hostname} "
89
+ f"(PID: {lock_info.pid}). "
90
+ f"Expires at: {time.ctime(lock_info.expires_at)}."
91
+ )
92
+ raise LockError(
93
+ f"Invalid lock file exists at {self.path}. "
94
+ "This file should be removed."
95
+ )
96
+
97
+ if time.time() - start_time > wait_timeout:
98
+ raise LockError(f"Timeout waiting for lock after {wait_timeout}s")
99
+
100
+ time.sleep(0.5)
101
+
102
+ def _try_acquire(self: Self) -> bool:
103
+ """Try to acquire lock. Returns True if successful.
104
+
105
+ This method creates the lock as a temporary file first to avoid race conditions.
106
+ Because we are operating on networked filesystems (NFS) the situation is a bit
107
+ more complex, but os.link() is an atomic operation, so try to link the temp file
108
+ after creating it.
109
+
110
+ Returns:
111
+ True if acquiring the lock succeeded
112
+ """
113
+ acquired_at = time.time()
114
+ lock_info = LockInfo(
115
+ pid=os.getpid(),
116
+ hostname=socket.gethostname(),
117
+ user=os.getenv("USER", "unknown"),
118
+ acquired_at=acquired_at,
119
+ expires_at=acquired_at + self._timeout_seconds,
120
+ )
121
+
122
+ temp_path = (
123
+ self.path.parent
124
+ / f".lock.{lock_info.hostname}.{lock_info.pid}.{uuid.uuid4().hex[:8]}"
125
+ )
126
+ lock_fd: int | None = None
127
+ try:
128
+ # O_WRONLY will allow only this process to write to the lock file.
129
+ lock_fd = os.open(temp_path, os.O_CREAT | os.O_WRONLY | os.O_EXCL)
130
+ try:
131
+ json_data = lock_info.model_dump_json(by_alias=True, indent=2)
132
+ os.write(lock_fd, json_data.encode())
133
+ os.fsync(lock_fd)
134
+ except Exception:
135
+ os.close(lock_fd)
136
+ raise
137
+
138
+ os.close(lock_fd)
139
+ lock_fd = None
140
+
141
+ try:
142
+ os.link(temp_path, self.path)
143
+ self._cache = lock_info
144
+ self._acquired_at = acquired_at
145
+ return True
146
+ except (OSError, FileExistsError):
147
+ return False
148
+ finally: # Clean up temp file before we leave
149
+ if lock_fd is not None:
150
+ with contextlib.suppress(OSError):
151
+ os.close(lock_fd)
152
+ with contextlib.suppress(OSError):
153
+ temp_path.unlink()
154
+
155
+ def is_locked(self: Self) -> bool:
156
+ """Returns whether or not the lock is locked by anyone.
157
+
158
+ This does a force load on the lock file.
159
+ """
160
+ lock_info = self._safe_load(force=True)
161
+ if not lock_info:
162
+ return False
163
+ return time.time() < lock_info.expires_at
164
+
165
+ def is_acquired(self: Self) -> bool:
166
+ """Returns whether or not the lock is currently acquired by this instance."""
167
+ if self._cache is None or self._acquired_at is None:
168
+ return False
169
+ return self._is_mine(self._cache) and not self._is_stale()
170
+
171
+ def refresh(self: Self) -> None:
172
+ """Refresh/extend the lock expiration time.
173
+
174
+ Raises:
175
+ LockError: If we don't hold the lock or it's invalid
176
+ """
177
+ if not self.exists:
178
+ raise LockError("Cannot refresh: lock file does not exist")
179
+
180
+ lock_info = self._safe_load()
181
+ if not lock_info or not self._is_mine(lock_info):
182
+ raise LockError(
183
+ "Cannot refresh: lock file is held by another process or host."
184
+ )
185
+
186
+ lock_info.expires_at = time.time() + self._timeout_seconds
187
+ self.save(lock_info)
188
+
189
+ def release(self: Self) -> None:
190
+ """Release the lock."""
191
+ if self.exists:
192
+ lock_info = self._safe_load()
193
+ if lock_info and self._is_mine(lock_info):
194
+ with contextlib.suppress(ValueError):
195
+ self.path.unlink()
196
+
197
+ self._acquired_at = None
198
+ self._cache = None
199
+
200
+ def save(self: Self, data: LockInfo) -> None:
201
+ """Save the lockfile in an NFS-atomic manner.
202
+
203
+ This overrides save() from the Pydantic resource manager.
204
+ """
205
+ lock_info = self._safe_load()
206
+ if not lock_info or not self._is_mine(lock_info):
207
+ raise LockError(
208
+ "Failed to save lock: lock file is held by another process or host."
209
+ )
210
+
211
+ temp_path = Path(f"{self.path}.tmp.{uuid.uuid4().hex[:8]}")
212
+ try:
213
+ with open(temp_path, "w", encoding="utf-8") as f:
214
+ f.write(data.model_dump_json(indent=2))
215
+ f.flush()
216
+ os.fsync(f.fileno())
217
+ temp_path.replace(self.path)
218
+ self._cache = data
219
+ except Exception as e:
220
+ with contextlib.suppress(OSError):
221
+ temp_path.unlink()
222
+ raise LockError(f"Failed to save lock: {e}") from e
223
+
224
+ def _is_mine(self: Self, lock_info: LockInfo) -> bool:
225
+ """Verifies if the calling process owns the lock."""
226
+ return (
227
+ lock_info.pid == os.getpid()
228
+ and lock_info.hostname == socket.gethostname()
229
+ and lock_info.acquired_at == self._acquired_at
230
+ )
231
+
232
+ def _safe_load(self: Self, force: bool = False) -> LockInfo | None:
233
+ """Load lock info, returning None if corrupted.
234
+
235
+ Because this file does not exist in a static state, wrap around loading it.
236
+ """
237
+ try:
238
+ return self.load(force=force)
239
+ except Exception:
240
+ return None
241
+
242
+ def _is_stale(self: Self) -> bool:
243
+ """Check if existing lock is stale (expired or process dead)."""
244
+ lock_info = self._safe_load()
245
+ if not lock_info:
246
+ return True
247
+
248
+ if time.time() > lock_info.expires_at:
249
+ return True
250
+
251
+ # If we aren't on the same host, we can't check the PID, so assume it's
252
+ # not stale.
253
+ if lock_info.hostname != socket.gethostname():
254
+ return False
255
+
256
+ try:
257
+ # Doesn't actually kill, just checks if it exists
258
+ os.kill(lock_info.pid, 0)
259
+ return False
260
+ except OSError:
261
+ return True
262
+
263
+ def __enter__(self: Self) -> Self:
264
+ """Context manager entry."""
265
+ self.acquire()
266
+ return self
267
+
268
+ def __exit__(
269
+ self: Self,
270
+ exc_type: type[BaseException] | None,
271
+ exc_val: BaseException | None,
272
+ exc_tb: TracebackType | None,
273
+ ) -> Literal[False]:
274
+ """Context manager exit."""
275
+ self.release()
276
+ return False
277
+
278
+ def __del__(self: Self) -> None:
279
+ """Clean-up if garbage collected."""
280
+ if self._acquired_at is not None:
281
+ self.release()
@@ -58,8 +58,8 @@ class PydanticResourceManager(Generic[T]):
58
58
  Validated Pydantic model
59
59
 
60
60
  Raises:
61
- FileNotFoundError: If the resource file doesn't exist
62
- ValidationError: If the data doesn't match the model schema
61
+ ValueError: If the resource file is missing or data does not match the
62
+ model schema
63
63
  """
64
64
  if self._cache is None or force:
65
65
  if not self.exists:
@@ -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.2.0'
32
- __version_tuple__ = version_tuple = (0, 2, 0)
31
+ __version__ = version = '0.3.1'
32
+ __version_tuple__ = version_tuple = (0, 3, 1)
33
33
 
34
- __commit_id__ = commit_id = 'g944134f32'
34
+ __commit_id__ = commit_id = 'g7197724f6'
@@ -0,0 +1,30 @@
1
+ """Models related to locking a .fmu directory against writes."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from fmu.settings import __version__
6
+ from fmu.settings.types import VersionStr
7
+
8
+
9
+ class LockInfo(BaseModel):
10
+ """Represents a .fmu directory lock file."""
11
+
12
+ pid: int
13
+ """Process ID of the lock holder."""
14
+
15
+ hostname: str
16
+ """Hostname where the lock was acquired."""
17
+
18
+ user: str
19
+ """User who acquired the lock."""
20
+
21
+ acquired_at: float
22
+ """Unix timestamp when lock was acquired."""
23
+
24
+ expires_at: float
25
+ """Unix timestamp when lock expires."""
26
+
27
+ version: VersionStr = Field(default=__version__)
28
+ """The fmu-settings version acquiring the lock.
29
+
30
+ Used for debugging."""
@@ -8,7 +8,7 @@ from pydantic import AwareDatetime, Field
8
8
 
9
9
  from fmu.datamodels.fmu_results.fields import Access, Masterdata, Model
10
10
  from fmu.settings import __version__
11
- from fmu.settings.types import ResettableBaseModel, VersionStr # noqa TC001
11
+ from fmu.settings.types import ResettableBaseModel, VersionStr # noqa: TC001
12
12
 
13
13
 
14
14
  class ProjectConfig(ResettableBaseModel):
@@ -5,7 +5,6 @@ from __future__ import annotations
5
5
  from datetime import UTC, datetime
6
6
  from pathlib import Path
7
7
  from typing import Annotated, Self
8
- from uuid import UUID # noqa TC003
9
8
 
10
9
  import annotated_types
11
10
  from pydantic import (
@@ -17,7 +16,7 @@ from pydantic import (
17
16
  )
18
17
 
19
18
  from fmu.settings import __version__
20
- from fmu.settings.types import ResettableBaseModel, VersionStr # noqa TC001
19
+ from fmu.settings.types import ResettableBaseModel, VersionStr # noqa: TC001
21
20
 
22
21
  RecentProjectDirectories = Annotated[list[Path], annotated_types.Len(0, 5)]
23
22
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fmu-settings
3
- Version: 0.2.0
3
+ Version: 0.3.1
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
@@ -19,10 +19,12 @@ src/fmu/settings/py.typed
19
19
  src/fmu/settings/types.py
20
20
  src/fmu/settings/_resources/__init__.py
21
21
  src/fmu/settings/_resources/config_managers.py
22
+ src/fmu/settings/_resources/lock_manager.py
22
23
  src/fmu/settings/_resources/pydantic_resource_manager.py
23
24
  src/fmu/settings/models/__init__.py
24
25
  src/fmu/settings/models/_enums.py
25
26
  src/fmu/settings/models/_mappings.py
27
+ src/fmu/settings/models/lock_info.py
26
28
  src/fmu/settings/models/project_config.py
27
29
  src/fmu/settings/models/user_config.py
28
30
  src/fmu_settings.egg-info/PKG-INFO
@@ -33,6 +35,7 @@ src/fmu_settings.egg-info/top_level.txt
33
35
  tests/conftest.py
34
36
  tests/test_fmu_dir.py
35
37
  tests/test_init.py
38
+ tests/test_resources/test_lock_manager.py
36
39
  tests/test_resources/test_project_config.py
37
40
  tests/test_resources/test_resource_managers.py
38
41
  tests/test_resources/test_user_config.py
@@ -136,7 +136,7 @@ def user_config_model(user_config_dict: dict[str, Any]) -> UserConfig:
136
136
  return UserConfig.model_validate(user_config_dict)
137
137
 
138
138
 
139
- @pytest.fixture
139
+ @pytest.fixture(scope="function")
140
140
  def fmu_dir(tmp_path: Path, unix_epoch_utc: datetime) -> ProjectFMUDirectory:
141
141
  """Create an ProjectFMUDirectory instance for testing."""
142
142
  with (
@@ -326,3 +326,23 @@ def test_update_user_config_non_unique_recent_projects(
326
326
  updates = {"recent_project_directories": [Path("/foo/bar"), Path("/foo/bar")]}
327
327
  with pytest.raises(ValueError, match="unique entries"):
328
328
  user_fmu_dir.update_config(updates)
329
+
330
+
331
+ def test_acquire_lock_on_project_fmu(
332
+ fmu_dir: ProjectFMUDirectory,
333
+ ) -> None:
334
+ """Tests that a lock can be acquired on the project dir."""
335
+ fmu_dir._lock.acquire()
336
+ assert fmu_dir._lock.is_acquired()
337
+ assert fmu_dir._lock.exists
338
+ assert (fmu_dir.path / ".lock").exists()
339
+
340
+
341
+ def test_acquire_lock_on_user_fmu(
342
+ user_fmu_dir: UserFMUDirectory,
343
+ ) -> None:
344
+ """Tests that a lock can be acquired on the user dir."""
345
+ user_fmu_dir._lock.acquire()
346
+ assert user_fmu_dir._lock.is_acquired()
347
+ assert user_fmu_dir._lock.exists
348
+ assert (user_fmu_dir.path / ".lock").exists()
@@ -0,0 +1,585 @@
1
+ """Tests for LockManager."""
2
+
3
+ import json
4
+ import multiprocessing
5
+ import multiprocessing.queues
6
+ import os
7
+ import socket
8
+ import threading
9
+ import time
10
+ import uuid
11
+ from pathlib import Path
12
+ from unittest.mock import patch
13
+
14
+ import pytest
15
+ from pytest import MonkeyPatch
16
+
17
+ from fmu.settings._fmu_dir import ProjectFMUDirectory
18
+ from fmu.settings._resources.lock_manager import (
19
+ DEFAULT_LOCK_TIMEOUT,
20
+ LockError,
21
+ LockManager,
22
+ )
23
+ from fmu.settings.models.lock_info import LockInfo
24
+
25
+
26
+ def test_lock_manager_instantiation(fmu_dir: ProjectFMUDirectory) -> None:
27
+ """Tests basic facts about the LockManager."""
28
+ lock = LockManager(fmu_dir)
29
+
30
+ assert lock.fmu_dir == fmu_dir
31
+ assert lock.model_class == LockInfo
32
+ assert lock._cache is None
33
+ assert lock._timeout_seconds == DEFAULT_LOCK_TIMEOUT
34
+ assert lock._acquired_at is None
35
+
36
+ # Resource manager requires this to be implemented
37
+ assert lock.relative_path == Path(".lock")
38
+ assert lock.path == fmu_dir.path / lock.relative_path
39
+
40
+
41
+ def test_lock_acquire_creates_lock_file(
42
+ fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
43
+ ) -> None:
44
+ """Tests that acquiring a lock creates a correct lock file."""
45
+ monkeypatch.setenv("USER", "user")
46
+
47
+ lock = LockManager(fmu_dir)
48
+
49
+ assert lock.exists is False
50
+ with pytest.raises(
51
+ FileNotFoundError, match="Resource file for 'LockManager' not found"
52
+ ):
53
+ lock.load()
54
+
55
+ lock.acquire()
56
+ assert lock.exists
57
+ assert lock._acquired_at is not None
58
+
59
+ lock_info = lock.load()
60
+ assert lock._cache == lock_info
61
+ assert lock._acquired_at == lock_info.acquired_at
62
+ assert lock_info.pid == os.getpid()
63
+ assert lock_info.hostname == socket.gethostname()
64
+ assert lock_info.user == "user"
65
+ assert lock_info.expires_at == pytest.approx(
66
+ lock_info.acquired_at + DEFAULT_LOCK_TIMEOUT, rel=1e-05
67
+ )
68
+
69
+
70
+ def test_lock_as_context_manager(
71
+ fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
72
+ ) -> None:
73
+ """Tests that acquiring a ctx manager lock creates a correct lock file."""
74
+ monkeypatch.setenv("USER", "user")
75
+
76
+ with LockManager(fmu_dir) as lock:
77
+ assert lock.exists
78
+ assert lock._acquired_at is not None
79
+
80
+ lock_info = lock.load()
81
+ assert lock._cache == lock_info
82
+ assert lock._acquired_at == lock_info.acquired_at
83
+ assert lock_info.pid == os.getpid()
84
+ assert lock_info.hostname == socket.gethostname()
85
+ assert lock_info.user == "user"
86
+ assert lock_info.expires_at == pytest.approx(
87
+ lock_info.acquired_at + DEFAULT_LOCK_TIMEOUT, rel=1e-05
88
+ )
89
+
90
+ assert (fmu_dir.path / ".lock").exists() is False
91
+
92
+
93
+ def test_lock_acquire_raises_if_already_acquired(fmu_dir: ProjectFMUDirectory) -> None:
94
+ """Tests that a lock cannot be acquired twice."""
95
+ lock = LockManager(fmu_dir)
96
+ lock.acquire()
97
+ assert lock.is_locked()
98
+ assert lock.is_acquired()
99
+ with pytest.raises(LockError, match="Lock already acquired"):
100
+ lock.acquire()
101
+
102
+
103
+ @pytest.mark.xfail(reason="Lock is not race consistent yet")
104
+ def test_lock_acquire_race_in_threads(
105
+ fmu_dir: ProjectFMUDirectory,
106
+ ) -> None:
107
+ """Tests that under thread race conditions one lock succeeds, one fails."""
108
+ results = []
109
+ errors = []
110
+
111
+ def acquire_lock() -> None:
112
+ """Creates, acquires, and releases a lock."""
113
+ try:
114
+ lock = LockManager(fmu_dir)
115
+ lock.acquire()
116
+ results.append("success")
117
+ except LockError as e:
118
+ errors.append(e)
119
+
120
+ thread1 = threading.Thread(target=acquire_lock)
121
+ thread2 = threading.Thread(target=acquire_lock)
122
+
123
+ thread1.start()
124
+ thread2.start()
125
+
126
+ thread1.join()
127
+ thread2.join()
128
+
129
+ assert len(results) == 1
130
+ assert len(errors) == 1
131
+
132
+
133
+ def _acquire_lock(
134
+ fmu_dir: ProjectFMUDirectory,
135
+ result_queue: multiprocessing.queues.Queue, # type: ignore
136
+ ) -> None:
137
+ """Creates, acquires, and releases a lock.
138
+
139
+ This is at the module level so it can be pickled by the process test.
140
+ """
141
+ try:
142
+ lock = LockManager(fmu_dir)
143
+ lock.acquire()
144
+ result_queue.put(("success", None))
145
+ except LockError as e:
146
+ result_queue.put(("error", str(e)))
147
+
148
+
149
+ @pytest.mark.xfail(reason="Lock is not race consistent yet")
150
+ def test_lock_acquire_race_in_processes(
151
+ fmu_dir: ProjectFMUDirectory,
152
+ ) -> None:
153
+ """Tests that under same process race conditions, one lock succeeds, one fails."""
154
+ # See https://github.com/python/cpython/issues/99509 for type ignore
155
+ result_queue = multiprocessing.Queue() # type: ignore
156
+ process1 = multiprocessing.Process(
157
+ target=_acquire_lock, args=(fmu_dir, result_queue)
158
+ )
159
+ process2 = multiprocessing.Process(
160
+ target=_acquire_lock, args=(fmu_dir, result_queue)
161
+ )
162
+
163
+ process1.start()
164
+ process2.start()
165
+ process1.join()
166
+ process2.join()
167
+
168
+ results = []
169
+ while not result_queue.empty():
170
+ results.append(result_queue.get())
171
+
172
+ successes = [r for r in results if r[0] == "success"]
173
+ errors = [r for r in results if r[0] == "error"]
174
+
175
+ assert len(successes) == 1
176
+ assert len(errors) == 1
177
+
178
+
179
+ def test_lock_acquire_raises_if_invalid_wait_period(
180
+ fmu_dir: ProjectFMUDirectory,
181
+ ) -> None:
182
+ """Tests that a lock acquire requires a positive timeout."""
183
+ lock = LockManager(fmu_dir)
184
+ with pytest.raises(ValueError, match="wait_timeout must be positive"):
185
+ lock.acquire(wait=True, wait_timeout=-1)
186
+ lock.acquire(wait=True, wait_timeout=0.01)
187
+
188
+
189
+ def test_lock_acquire_over_expired_lock(fmu_dir: ProjectFMUDirectory) -> None:
190
+ """Tests that a stale lock file will be unlinked and overwritten."""
191
+ stale_lock = LockManager(fmu_dir, timeout_seconds=-1) # Expired
192
+ stale_lock.acquire()
193
+ assert stale_lock.is_locked() is False
194
+ assert stale_lock.is_acquired() is False
195
+ assert stale_lock._is_stale() is True
196
+ stale_lock_info = stale_lock.load()
197
+
198
+ lock = LockManager(fmu_dir)
199
+ lock.acquire()
200
+ lock_info = lock.load()
201
+
202
+ assert lock.is_locked()
203
+ assert lock.is_acquired()
204
+ assert stale_lock.path == lock.path
205
+ assert lock_info != stale_lock_info
206
+
207
+
208
+ def test_no_wait_invalid_lock_file_exists(fmu_dir: ProjectFMUDirectory) -> None:
209
+ """Tests that an existing, invalid .lock file raises.
210
+
211
+ This should not really occur, but it's theoretically possible the lock file is
212
+ overwritten to an invalid state between its stale check.
213
+ """
214
+ fmu_dir.write_text_file(".lock", "")
215
+ lock = LockManager(fmu_dir)
216
+
217
+ # This shouldn't be possible, but test that it's caught anyway
218
+ with (
219
+ patch.object(lock, "_is_stale", return_value=False),
220
+ pytest.raises(LockError, match="Invalid lock file exists"),
221
+ ):
222
+ lock.acquire()
223
+
224
+
225
+ def test_lock_acquire_when_fresh_lock_exists_without_timeout(
226
+ fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
227
+ ) -> None:
228
+ """Tests that trying to acquire a fresh lock without timeout fails."""
229
+ monkeypatch.setenv("USER", "user")
230
+
231
+ lock = LockManager(fmu_dir)
232
+ lock.acquire()
233
+
234
+ bad_lock = LockManager(fmu_dir)
235
+ with pytest.raises(LockError, match="Lock file is held by user@"):
236
+ bad_lock.acquire()
237
+
238
+
239
+ def test_lock_with_wait_timeout_raises_after_timeout(
240
+ fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
241
+ ) -> None:
242
+ """Tests that trying to acquire a fresh lock with timeout succeeds."""
243
+ lock = LockManager(fmu_dir)
244
+ lock.acquire()
245
+
246
+ wait_lock = LockManager(fmu_dir)
247
+ with pytest.raises(LockError, match="Timeout waiting for lock"):
248
+ wait_lock.acquire(wait=True, wait_timeout=0.25)
249
+
250
+
251
+ def test_lock_with_wait_timeout_succeeds_after_release(
252
+ fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
253
+ ) -> None:
254
+ """Tests that trying to acquire a fresh lock with timeout succeeds."""
255
+ lock = LockManager(fmu_dir)
256
+ lock.acquire()
257
+
258
+ def try_acquire_second_lock() -> LockManager:
259
+ wait_lock = LockManager(fmu_dir)
260
+ wait_lock.acquire(wait=True, wait_timeout=1.0)
261
+ return wait_lock
262
+
263
+ thread = threading.Thread(target=try_acquire_second_lock)
264
+ thread.start()
265
+
266
+ time.sleep(0.15)
267
+ lock.release()
268
+ thread.join()
269
+ assert not thread.is_alive()
270
+
271
+
272
+ def test_is_stale_expired(fmu_dir: ProjectFMUDirectory) -> None:
273
+ """Tests is_stale if lock has expired."""
274
+ lock = LockManager(fmu_dir, timeout_seconds=-1) # Expired
275
+ lock.acquire()
276
+ assert lock._is_stale() is True
277
+
278
+
279
+ def test_is_stale_not_expired(fmu_dir: ProjectFMUDirectory) -> None:
280
+ """Tests is_stale if lock has not expired."""
281
+ lock = LockManager(fmu_dir)
282
+ lock.acquire()
283
+ assert lock._is_stale() is False
284
+
285
+
286
+ def test_is_stale_load_fails(fmu_dir: ProjectFMUDirectory) -> None:
287
+ """Tests is_stale if loading the lock file fails."""
288
+ lock = LockManager(fmu_dir)
289
+ lock.acquire()
290
+ with patch.object(lock, "_safe_load", return_value=None):
291
+ assert lock._is_stale() is True
292
+
293
+
294
+ def test_is_stale_bad_hostname(fmu_dir: ProjectFMUDirectory) -> None:
295
+ """Tests is_stale if lock check occurs from different host."""
296
+ lock = LockManager(fmu_dir)
297
+ lock.acquire()
298
+ assert lock._is_stale() is False
299
+ with patch(
300
+ "fmu.settings._resources.lock_manager.socket.gethostname", return_value="foo"
301
+ ):
302
+ assert lock._is_stale() is False
303
+
304
+
305
+ def test_is_stale_invalid_pid(
306
+ fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
307
+ ) -> None:
308
+ """Tests helper method check if lock pid does not exist."""
309
+ lock = LockManager(fmu_dir)
310
+ lock.acquire()
311
+ assert lock._is_stale() is False
312
+ assert lock._cache is not None
313
+ lock._cache.pid = 99999999 # Should be an impossible pid
314
+ assert lock._is_stale() is True
315
+
316
+
317
+ def test_try_acquire_succeeds_as_expected(
318
+ fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
319
+ ) -> None:
320
+ """Tests that try_acquire creates a correct lock file."""
321
+ monkeypatch.setenv("USER", "user")
322
+ time_time = 1234.5
323
+ temp_uuid = uuid.uuid4()
324
+ lock = LockManager(fmu_dir)
325
+ with (
326
+ patch("time.time", return_value=time_time),
327
+ patch("os.getpid", return_value=123),
328
+ patch("socket.gethostname", return_value="foo"),
329
+ patch("pathlib.Path.unlink") as mock_unlink,
330
+ patch("uuid.uuid4", return_value=temp_uuid),
331
+ ):
332
+ assert lock._try_acquire() is True
333
+ assert lock._acquired_at == time_time
334
+ assert lock._cache == LockInfo(
335
+ pid=123,
336
+ hostname="foo",
337
+ user="user",
338
+ acquired_at=time_time,
339
+ expires_at=time_time + DEFAULT_LOCK_TIMEOUT,
340
+ )
341
+ mock_unlink.assert_called_once()
342
+
343
+ lock_info = lock._cache
344
+ temp_lockfile = (
345
+ fmu_dir.path / f".lock.{lock_info.hostname}.{lock_info.pid}.{temp_uuid.hex[:8]}"
346
+ )
347
+ # We mocked unlink(), so the temporary lockfile should still exist.
348
+ assert temp_lockfile.exists()
349
+ assert LockInfo.model_validate(json.loads(temp_lockfile.read_text())) == lock_info
350
+
351
+ assert lock.path.exists()
352
+ assert LockInfo.model_validate(json.loads(lock.path.read_text())) == lock_info
353
+
354
+
355
+ def test_try_acquire_fails_when_writing_temp_file(
356
+ fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
357
+ ) -> None:
358
+ """Tests that try_acquire raises when failing to write the lock file.."""
359
+ lock = LockManager(fmu_dir)
360
+ with (
361
+ patch("os.write", side_effect=OSError("oops")),
362
+ patch("os.close") as mock_close,
363
+ patch("pathlib.Path.unlink") as mock_unlink,
364
+ pytest.raises(OSError, match="oops"),
365
+ ):
366
+ lock._try_acquire()
367
+ mock_close.assert_called_twice()
368
+ mock_unlink.assert_called_once()
369
+
370
+
371
+ def test_try_acquire_fails_when_linking_temp_file(
372
+ fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
373
+ ) -> None:
374
+ """Tests that try_acquire raises when failing to link the lock file.."""
375
+ lock = LockManager(fmu_dir)
376
+ with (
377
+ patch("os.link") as mock_link,
378
+ patch("os.close", side_effect=FileExistsError) as mock_close,
379
+ patch("pathlib.Path.unlink") as mock_unlink,
380
+ pytest.raises(FileExistsError),
381
+ ):
382
+ assert lock._try_acquire() is False
383
+ mock_link.assert_called_once_with(lock.path)
384
+ mock_close.assert_called_twice()
385
+ mock_unlink.assert_called_once()
386
+
387
+
388
+ def test_is_locked_expected(
389
+ fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
390
+ ) -> None:
391
+ """Tests is_locked under expected, simple conditions."""
392
+ lock = LockManager(fmu_dir)
393
+ assert lock.is_locked() is False
394
+ lock.acquire()
395
+ assert lock.is_locked() is True
396
+ lock.release()
397
+ assert lock.is_locked() is False
398
+
399
+
400
+ def test_is_locked_by_other_process(
401
+ fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
402
+ ) -> None:
403
+ """Tests is_locked when another process has the lock."""
404
+ lock = LockManager(fmu_dir)
405
+ assert lock.is_locked() is False
406
+ with patch("os.getpid", return_value=-1234):
407
+ lock.acquire()
408
+
409
+ assert lock.is_locked() is True
410
+
411
+ with patch("os.getpid", return_value=-1234):
412
+ lock.release()
413
+ assert lock.is_locked() is False
414
+
415
+
416
+ def test_is_acquired_expected(
417
+ fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
418
+ ) -> None:
419
+ """Tests is_acquired under expected conditions."""
420
+ lock = LockManager(fmu_dir)
421
+ assert lock.is_acquired() is False
422
+ lock.acquire()
423
+ assert lock.is_acquired() is True
424
+
425
+
426
+ def test_is_acquired_unexpected_members(
427
+ fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
428
+ ) -> None:
429
+ """Tests is_acquired under expected member conditions."""
430
+ lock = LockManager(fmu_dir)
431
+ assert lock.is_acquired() is False
432
+ lock.acquire()
433
+ assert lock.is_acquired() is True
434
+ lock_info = lock._cache
435
+ lock._cache = None
436
+ assert lock.is_acquired() is False
437
+ lock._cache = lock_info
438
+ lock._acquired_at = None
439
+ assert lock.is_acquired() is False
440
+
441
+
442
+ @pytest.mark.parametrize(
443
+ "is_mine, is_stale, expected",
444
+ [
445
+ (True, True, False),
446
+ (True, False, True),
447
+ (False, True, False),
448
+ (False, False, False),
449
+ ],
450
+ )
451
+ def test_is_acquired_unexpected_methods(
452
+ fmu_dir: ProjectFMUDirectory,
453
+ monkeypatch: MonkeyPatch,
454
+ is_mine: bool,
455
+ is_stale: bool,
456
+ expected: bool,
457
+ ) -> None:
458
+ """Tests is_acquired under expected method conditions."""
459
+ lock = LockManager(fmu_dir)
460
+ assert lock.is_acquired() is False
461
+ lock.acquire()
462
+
463
+ with (
464
+ patch.object(lock, "_is_mine", return_value=is_mine),
465
+ patch.object(lock, "_is_stale", return_value=is_stale),
466
+ ):
467
+ assert lock.is_acquired() is expected
468
+
469
+
470
+ def test_refresh_works_as_expected(
471
+ fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
472
+ ) -> None:
473
+ """Tests refresh works as expected."""
474
+ lock = LockManager(fmu_dir)
475
+
476
+ start_time = 1234.5
477
+ refresh_time = 2345.6
478
+ with patch("time.time", return_value=start_time):
479
+ lock.acquire()
480
+ assert lock._cache is not None
481
+ assert lock._cache.expires_at == start_time + DEFAULT_LOCK_TIMEOUT
482
+
483
+ with patch("time.time", return_value=refresh_time):
484
+ lock.refresh()
485
+ assert lock._acquired_at == start_time
486
+ assert lock._cache.expires_at == refresh_time + DEFAULT_LOCK_TIMEOUT
487
+
488
+ lock_info = LockInfo.model_validate(json.loads(lock.path.read_text()))
489
+ assert lock_info.expires_at == refresh_time + DEFAULT_LOCK_TIMEOUT
490
+
491
+
492
+ def test_refresh_without_lock_file(
493
+ fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
494
+ ) -> None:
495
+ """Tests refresh when lock file is not present."""
496
+ lock = LockManager(fmu_dir)
497
+ with pytest.raises(LockError, match="does not exist"):
498
+ lock.refresh()
499
+
500
+ lock.acquire()
501
+ lock.path.unlink() # It was deleted or something.
502
+ with pytest.raises(LockError, match="does not exist"):
503
+ lock.refresh()
504
+
505
+
506
+ def test_refresh_without_owning_lock(
507
+ fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
508
+ ) -> None:
509
+ """Tests refresh when lock file is not owned by the process."""
510
+ lock = LockManager(fmu_dir)
511
+ with patch("os.getpid", return_value=-1234):
512
+ lock.acquire()
513
+ with pytest.raises(LockError, match="held by another process"):
514
+ lock.refresh()
515
+
516
+
517
+ def test_lock_release_unlinks_lock_file(fmu_dir: ProjectFMUDirectory) -> None:
518
+ """Tests that releasing a lock removes the lock file."""
519
+ lock = LockManager(fmu_dir)
520
+
521
+ assert lock.exists is False
522
+ lock.acquire()
523
+ assert lock.exists
524
+ lock.release()
525
+ assert lock.exists is False
526
+ assert lock._cache is None
527
+ assert lock.path.exists() is False
528
+
529
+
530
+ def test_safe_load(fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch) -> None:
531
+ """Tests the safe load helper method."""
532
+ lock = LockManager(fmu_dir)
533
+ lock.acquire()
534
+ assert lock._cache is not None
535
+ assert lock._safe_load() == lock._cache
536
+
537
+ lock.release()
538
+ lock.path.write_text("a")
539
+ assert lock._safe_load() is None
540
+
541
+
542
+ def test_save_expected(fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch) -> None:
543
+ """Tests that save works as expected."""
544
+ lock = LockManager(fmu_dir)
545
+ lock.acquire()
546
+
547
+ lock_info = lock.load()
548
+ new_expires_at = 123.4
549
+ lock_info.expires_at = new_expires_at
550
+
551
+ temp_uuid = uuid.uuid4()
552
+ with patch("uuid.uuid4", return_value=temp_uuid):
553
+ lock.save(lock_info)
554
+
555
+ lock_info = lock.load()
556
+ assert lock_info.expires_at == new_expires_at
557
+ assert Path(f"{lock.path}.tmp.{temp_uuid.hex[:8]}").exists() is False
558
+ assert LockInfo.model_validate(json.loads(lock.path.read_text())) == lock_info
559
+
560
+
561
+ def test_save_raises(fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch) -> None:
562
+ """Tests that save fails as expected under failing conditions."""
563
+ lock = LockManager(fmu_dir)
564
+ lock.acquire()
565
+
566
+ lock_info = lock.load()
567
+ with (
568
+ patch("pathlib.Path.replace", side_effect=OSError("oops")) as mock_replace,
569
+ pytest.raises(LockError, match="oops"),
570
+ ):
571
+ lock.save(lock_info)
572
+ mock_replace.assert_called_once_with(lock.path)
573
+
574
+
575
+ def test_save_not_owned(fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch) -> None:
576
+ """Tests that save fails when lock is not owned by process."""
577
+ lock = LockManager(fmu_dir)
578
+ lock.acquire()
579
+
580
+ lock_info = lock.load()
581
+ with (
582
+ patch.object(lock, "_is_mine", return_value=False),
583
+ pytest.raises(LockError, match="lock file is held by another"),
584
+ ):
585
+ lock.save(lock_info)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -6,8 +6,8 @@ from pathlib import Path
6
6
  from typing import Any
7
7
 
8
8
  import pytest
9
-
10
9
  from fmu.datamodels.fmu_results.fields import Access, Model, Smda
10
+
11
11
  from fmu.settings._fmu_dir import ProjectFMUDirectory, UserFMUDirectory
12
12
  from fmu.settings._resources.config_managers import (
13
13
  ProjectConfigManager,