fmu-settings 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of fmu-settings might be problematic. Click here for more details.
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/PKG-INFO +1 -1
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu/settings/__init__.py +1 -1
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu/settings/_fmu_dir.py +3 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu/settings/_resources/config_managers.py +1 -2
- fmu_settings-0.3.0/src/fmu/settings/_resources/lock_manager.py +271 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu/settings/_resources/pydantic_resource_manager.py +2 -2
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu/settings/_version.py +3 -3
- fmu_settings-0.3.0/src/fmu/settings/models/lock_info.py +30 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu/settings/models/project_config.py +1 -1
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu/settings/models/user_config.py +1 -2
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu_settings.egg-info/PKG-INFO +1 -1
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu_settings.egg-info/SOURCES.txt +3 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/tests/conftest.py +1 -1
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/tests/test_fmu_dir.py +20 -0
- fmu_settings-0.3.0/tests/test_resources/test_lock_manager.py +554 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/.coveragerc +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/.github/pull_request_template.md +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/.github/workflows/ci.yml +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/.github/workflows/codeql.yml +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/.github/workflows/publish.yml +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/.gitignore +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/CONTRIBUTING.md +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/LICENSE +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/README.md +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/SECURITY.md +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/pyproject.toml +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/setup.cfg +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu/__init__.py +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu/settings/_init.py +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu/settings/_logging.py +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu/settings/_resources/__init__.py +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu/settings/models/__init__.py +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu/settings/models/_enums.py +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu/settings/models/_mappings.py +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu/settings/py.typed +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu/settings/types.py +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu_settings.egg-info/dependency_links.txt +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu_settings.egg-info/requires.txt +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu_settings.egg-info/top_level.txt +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/tests/test_init.py +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/tests/test_resources/test_project_config.py +1 -1
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/tests/test_resources/test_resource_managers.py +0 -0
- {fmu_settings-0.2.0 → fmu_settings-0.3.0}/tests/test_resources/test_user_config.py +0 -0
|
@@ -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
|
|
13
|
+
from fmu.settings.types import ResettableBaseModel # noqa: TC001
|
|
15
14
|
|
|
16
15
|
from .pydantic_resource_manager import PydanticResourceManager
|
|
17
16
|
|
|
@@ -0,0 +1,271 @@
|
|
|
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 currently locked."""
|
|
157
|
+
if self._cache is None or self._acquired_at is None:
|
|
158
|
+
return False
|
|
159
|
+
return self._is_mine(self._cache) and not self._is_stale()
|
|
160
|
+
|
|
161
|
+
def refresh(self: Self) -> None:
|
|
162
|
+
"""Refresh/extend the lock expiration time.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
LockError: If we don't hold the lock or it's invalid
|
|
166
|
+
"""
|
|
167
|
+
if not self.exists:
|
|
168
|
+
raise LockError("Cannot refresh: lock file does not exist")
|
|
169
|
+
|
|
170
|
+
lock_info = self._safe_load()
|
|
171
|
+
if not lock_info or not self._is_mine(lock_info):
|
|
172
|
+
raise LockError(
|
|
173
|
+
"Cannot refresh: lock file is held by another process or host."
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
lock_info.expires_at = time.time() + self._timeout_seconds
|
|
177
|
+
self.save(lock_info)
|
|
178
|
+
|
|
179
|
+
def release(self: Self) -> None:
|
|
180
|
+
"""Release the lock."""
|
|
181
|
+
if self.exists:
|
|
182
|
+
lock_info = self._safe_load()
|
|
183
|
+
if lock_info and self._is_mine(lock_info):
|
|
184
|
+
with contextlib.suppress(ValueError):
|
|
185
|
+
self.path.unlink()
|
|
186
|
+
|
|
187
|
+
self._acquired_at = None
|
|
188
|
+
self._cache = None
|
|
189
|
+
|
|
190
|
+
def save(self: Self, data: LockInfo) -> None:
|
|
191
|
+
"""Save the lockfile in an NFS-atomic manner.
|
|
192
|
+
|
|
193
|
+
This overrides save() from the Pydantic resource manager.
|
|
194
|
+
"""
|
|
195
|
+
lock_info = self._safe_load()
|
|
196
|
+
if not lock_info or not self._is_mine(lock_info):
|
|
197
|
+
raise LockError(
|
|
198
|
+
"Failed to save lock: lock file is held by another process or host."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
temp_path = Path(f"{self.path}.tmp.{uuid.uuid4().hex[:8]}")
|
|
202
|
+
try:
|
|
203
|
+
with open(temp_path, "w", encoding="utf-8") as f:
|
|
204
|
+
f.write(data.model_dump_json(indent=2))
|
|
205
|
+
f.flush()
|
|
206
|
+
os.fsync(f.fileno())
|
|
207
|
+
temp_path.replace(self.path)
|
|
208
|
+
self._cache = data
|
|
209
|
+
except Exception as e:
|
|
210
|
+
with contextlib.suppress(OSError):
|
|
211
|
+
temp_path.unlink()
|
|
212
|
+
raise LockError(f"Failed to save lock: {e}") from e
|
|
213
|
+
|
|
214
|
+
def _is_mine(self: Self, lock_info: LockInfo) -> bool:
|
|
215
|
+
"""Verifies if the calling process owns the lock."""
|
|
216
|
+
return (
|
|
217
|
+
lock_info.pid == os.getpid()
|
|
218
|
+
and lock_info.hostname == socket.gethostname()
|
|
219
|
+
and lock_info.acquired_at == self._acquired_at
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def _safe_load(self: Self) -> LockInfo | None:
|
|
223
|
+
"""Load lock info, returning None if corrupted.
|
|
224
|
+
|
|
225
|
+
Because this file does not exist in a static state, wrap around loading it.
|
|
226
|
+
"""
|
|
227
|
+
try:
|
|
228
|
+
return self.load()
|
|
229
|
+
except Exception:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
def _is_stale(self: Self) -> bool:
|
|
233
|
+
"""Check if existing lock is stale (expired or process dead)."""
|
|
234
|
+
lock_info = self._safe_load()
|
|
235
|
+
if not lock_info:
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
if time.time() > lock_info.expires_at:
|
|
239
|
+
return True
|
|
240
|
+
|
|
241
|
+
# If we aren't on the same host, we can't check the PID, so assume it's
|
|
242
|
+
# not stale.
|
|
243
|
+
if lock_info.hostname != socket.gethostname():
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
# Doesn't actually kill, just checks if it exists
|
|
248
|
+
os.kill(lock_info.pid, 0)
|
|
249
|
+
return False
|
|
250
|
+
except OSError:
|
|
251
|
+
return True
|
|
252
|
+
|
|
253
|
+
def __enter__(self: Self) -> Self:
|
|
254
|
+
"""Context manager entry."""
|
|
255
|
+
self.acquire()
|
|
256
|
+
return self
|
|
257
|
+
|
|
258
|
+
def __exit__(
|
|
259
|
+
self: Self,
|
|
260
|
+
exc_type: type[BaseException] | None,
|
|
261
|
+
exc_val: BaseException | None,
|
|
262
|
+
exc_tb: TracebackType | None,
|
|
263
|
+
) -> Literal[False]:
|
|
264
|
+
"""Context manager exit."""
|
|
265
|
+
self.release()
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
def __del__(self: Self) -> None:
|
|
269
|
+
"""Clean-up if garbage collected."""
|
|
270
|
+
if self._acquired_at is not None:
|
|
271
|
+
self.release()
|
{fmu_settings-0.2.0 → fmu_settings-0.3.0}/src/fmu/settings/_resources/pydantic_resource_manager.py
RENAMED
|
@@ -58,8 +58,8 @@ class PydanticResourceManager(Generic[T]):
|
|
|
58
58
|
Validated Pydantic model
|
|
59
59
|
|
|
60
60
|
Raises:
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
|
31
|
+
__version__ = version = '0.3.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 3, 0)
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
34
|
+
__commit_id__ = commit_id = 'g5fa25d14f'
|
|
@@ -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
|
|
|
@@ -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_locked()
|
|
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_locked()
|
|
347
|
+
assert user_fmu_dir._lock.exists
|
|
348
|
+
assert (user_fmu_dir.path / ".lock").exists()
|
|
@@ -0,0 +1,554 @@
|
|
|
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
|
+
with pytest.raises(LockError, match="Lock already acquired"):
|
|
99
|
+
lock.acquire()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.mark.xfail(reason="Lock is not race consistent yet")
|
|
103
|
+
def test_lock_acquire_race_in_threads(
|
|
104
|
+
fmu_dir: ProjectFMUDirectory,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Tests that under thread race conditions one lock succeeds, one fails."""
|
|
107
|
+
results = []
|
|
108
|
+
errors = []
|
|
109
|
+
|
|
110
|
+
def acquire_lock() -> None:
|
|
111
|
+
"""Creates, acquires, and releases a lock."""
|
|
112
|
+
try:
|
|
113
|
+
lock = LockManager(fmu_dir)
|
|
114
|
+
lock.acquire()
|
|
115
|
+
results.append("success")
|
|
116
|
+
except LockError as e:
|
|
117
|
+
errors.append(e)
|
|
118
|
+
|
|
119
|
+
thread1 = threading.Thread(target=acquire_lock)
|
|
120
|
+
thread2 = threading.Thread(target=acquire_lock)
|
|
121
|
+
|
|
122
|
+
thread1.start()
|
|
123
|
+
thread2.start()
|
|
124
|
+
|
|
125
|
+
thread1.join()
|
|
126
|
+
thread2.join()
|
|
127
|
+
|
|
128
|
+
assert len(results) == 1
|
|
129
|
+
assert len(errors) == 1
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _acquire_lock(
|
|
133
|
+
fmu_dir: ProjectFMUDirectory,
|
|
134
|
+
result_queue: multiprocessing.queues.Queue, # type: ignore
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Creates, acquires, and releases a lock.
|
|
137
|
+
|
|
138
|
+
This is at the module level so it can be pickled by the process test.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
lock = LockManager(fmu_dir)
|
|
142
|
+
lock.acquire()
|
|
143
|
+
result_queue.put(("success", None))
|
|
144
|
+
except LockError as e:
|
|
145
|
+
result_queue.put(("error", str(e)))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@pytest.mark.xfail(reason="Lock is not race consistent yet")
|
|
149
|
+
def test_lock_acquire_race_in_processes(
|
|
150
|
+
fmu_dir: ProjectFMUDirectory,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Tests that under same process race conditions, one lock succeeds, one fails."""
|
|
153
|
+
# See https://github.com/python/cpython/issues/99509 for type ignore
|
|
154
|
+
result_queue = multiprocessing.Queue() # type: ignore
|
|
155
|
+
process1 = multiprocessing.Process(
|
|
156
|
+
target=_acquire_lock, args=(fmu_dir, result_queue)
|
|
157
|
+
)
|
|
158
|
+
process2 = multiprocessing.Process(
|
|
159
|
+
target=_acquire_lock, args=(fmu_dir, result_queue)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
process1.start()
|
|
163
|
+
process2.start()
|
|
164
|
+
process1.join()
|
|
165
|
+
process2.join()
|
|
166
|
+
|
|
167
|
+
results = []
|
|
168
|
+
while not result_queue.empty():
|
|
169
|
+
results.append(result_queue.get())
|
|
170
|
+
|
|
171
|
+
successes = [r for r in results if r[0] == "success"]
|
|
172
|
+
errors = [r for r in results if r[0] == "error"]
|
|
173
|
+
|
|
174
|
+
assert len(successes) == 1
|
|
175
|
+
assert len(errors) == 1
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_lock_acquire_raises_if_invalid_wait_period(
|
|
179
|
+
fmu_dir: ProjectFMUDirectory,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Tests that a lock acquire requires a positive timeout."""
|
|
182
|
+
lock = LockManager(fmu_dir)
|
|
183
|
+
with pytest.raises(ValueError, match="wait_timeout must be positive"):
|
|
184
|
+
lock.acquire(wait=True, wait_timeout=-1)
|
|
185
|
+
lock.acquire(wait=True, wait_timeout=0.01)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_lock_acquire_over_expired_lock(fmu_dir: ProjectFMUDirectory) -> None:
|
|
189
|
+
"""Tests that a stale lock file will be unlinked and overwritten."""
|
|
190
|
+
stale_lock = LockManager(fmu_dir, timeout_seconds=-1) # Expired
|
|
191
|
+
stale_lock.acquire()
|
|
192
|
+
assert stale_lock.is_locked() is False
|
|
193
|
+
assert stale_lock._is_stale() is True
|
|
194
|
+
stale_lock_info = stale_lock.load()
|
|
195
|
+
|
|
196
|
+
lock = LockManager(fmu_dir)
|
|
197
|
+
lock.acquire()
|
|
198
|
+
lock_info = lock.load()
|
|
199
|
+
|
|
200
|
+
assert lock.is_locked()
|
|
201
|
+
assert stale_lock.path == lock.path
|
|
202
|
+
assert lock_info != stale_lock_info
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_no_wait_invalid_lock_file_exists(fmu_dir: ProjectFMUDirectory) -> None:
|
|
206
|
+
"""Tests that an existing, invalid .lock file raises.
|
|
207
|
+
|
|
208
|
+
This should not really occur, but it's theoretically possible the lock file is
|
|
209
|
+
overwritten to an invalid state between its stale check.
|
|
210
|
+
"""
|
|
211
|
+
fmu_dir.write_text_file(".lock", "")
|
|
212
|
+
lock = LockManager(fmu_dir)
|
|
213
|
+
|
|
214
|
+
# This shouldn't be possible, but test that it's caught anyway
|
|
215
|
+
with (
|
|
216
|
+
patch.object(lock, "_is_stale", return_value=False),
|
|
217
|
+
pytest.raises(LockError, match="Invalid lock file exists"),
|
|
218
|
+
):
|
|
219
|
+
lock.acquire()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_lock_acquire_when_fresh_lock_exists_without_timeout(
|
|
223
|
+
fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
|
|
224
|
+
) -> None:
|
|
225
|
+
"""Tests that trying to acquire a fresh lock without timeout fails."""
|
|
226
|
+
monkeypatch.setenv("USER", "user")
|
|
227
|
+
|
|
228
|
+
lock = LockManager(fmu_dir)
|
|
229
|
+
lock.acquire()
|
|
230
|
+
|
|
231
|
+
bad_lock = LockManager(fmu_dir)
|
|
232
|
+
with pytest.raises(LockError, match="Lock file is held by user@"):
|
|
233
|
+
bad_lock.acquire()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_lock_with_wait_timeout_raises_after_timeout(
|
|
237
|
+
fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Tests that trying to acquire a fresh lock with timeout succeeds."""
|
|
240
|
+
lock = LockManager(fmu_dir)
|
|
241
|
+
lock.acquire()
|
|
242
|
+
|
|
243
|
+
wait_lock = LockManager(fmu_dir)
|
|
244
|
+
with pytest.raises(LockError, match="Timeout waiting for lock"):
|
|
245
|
+
wait_lock.acquire(wait=True, wait_timeout=0.25)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_lock_with_wait_timeout_succeeds_after_release(
|
|
249
|
+
fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
|
|
250
|
+
) -> None:
|
|
251
|
+
"""Tests that trying to acquire a fresh lock with timeout succeeds."""
|
|
252
|
+
lock = LockManager(fmu_dir)
|
|
253
|
+
lock.acquire()
|
|
254
|
+
|
|
255
|
+
def try_acquire_second_lock() -> LockManager:
|
|
256
|
+
wait_lock = LockManager(fmu_dir)
|
|
257
|
+
wait_lock.acquire(wait=True, wait_timeout=1.0)
|
|
258
|
+
return wait_lock
|
|
259
|
+
|
|
260
|
+
thread = threading.Thread(target=try_acquire_second_lock)
|
|
261
|
+
thread.start()
|
|
262
|
+
|
|
263
|
+
time.sleep(0.15)
|
|
264
|
+
lock.release()
|
|
265
|
+
thread.join()
|
|
266
|
+
assert not thread.is_alive()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_is_stale_expired(fmu_dir: ProjectFMUDirectory) -> None:
|
|
270
|
+
"""Tests is_stale if lock has expired."""
|
|
271
|
+
lock = LockManager(fmu_dir, timeout_seconds=-1) # Expired
|
|
272
|
+
lock.acquire()
|
|
273
|
+
assert lock._is_stale() is True
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_is_stale_not_expired(fmu_dir: ProjectFMUDirectory) -> None:
|
|
277
|
+
"""Tests is_stale if lock has not expired."""
|
|
278
|
+
lock = LockManager(fmu_dir)
|
|
279
|
+
lock.acquire()
|
|
280
|
+
assert lock._is_stale() is False
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_is_stale_load_fails(fmu_dir: ProjectFMUDirectory) -> None:
|
|
284
|
+
"""Tests is_stale if loading the lock file fails."""
|
|
285
|
+
lock = LockManager(fmu_dir)
|
|
286
|
+
lock.acquire()
|
|
287
|
+
with patch.object(lock, "_safe_load", return_value=None):
|
|
288
|
+
assert lock._is_stale() is True
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_is_stale_bad_hostname(fmu_dir: ProjectFMUDirectory) -> None:
|
|
292
|
+
"""Tests is_stale if lock check occurs from different host."""
|
|
293
|
+
lock = LockManager(fmu_dir)
|
|
294
|
+
lock.acquire()
|
|
295
|
+
assert lock._is_stale() is False
|
|
296
|
+
with patch(
|
|
297
|
+
"fmu.settings._resources.lock_manager.socket.gethostname", return_value="foo"
|
|
298
|
+
):
|
|
299
|
+
assert lock._is_stale() is False
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_is_stale_invalid_pid(
|
|
303
|
+
fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Tests helper method check if lock pid does not exist."""
|
|
306
|
+
lock = LockManager(fmu_dir)
|
|
307
|
+
lock.acquire()
|
|
308
|
+
assert lock._is_stale() is False
|
|
309
|
+
assert lock._cache is not None
|
|
310
|
+
lock._cache.pid = 99999999 # Should be an impossible pid
|
|
311
|
+
assert lock._is_stale() is True
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def test_try_acquire_succeeds_as_expected(
|
|
315
|
+
fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
|
|
316
|
+
) -> None:
|
|
317
|
+
"""Tests that try_acquire creates a correct lock file."""
|
|
318
|
+
monkeypatch.setenv("USER", "user")
|
|
319
|
+
time_time = 1234.5
|
|
320
|
+
temp_uuid = uuid.uuid4()
|
|
321
|
+
lock = LockManager(fmu_dir)
|
|
322
|
+
with (
|
|
323
|
+
patch("time.time", return_value=time_time),
|
|
324
|
+
patch("os.getpid", return_value=123),
|
|
325
|
+
patch("socket.gethostname", return_value="foo"),
|
|
326
|
+
patch("pathlib.Path.unlink") as mock_unlink,
|
|
327
|
+
patch("uuid.uuid4", return_value=temp_uuid),
|
|
328
|
+
):
|
|
329
|
+
assert lock._try_acquire() is True
|
|
330
|
+
assert lock._acquired_at == time_time
|
|
331
|
+
assert lock._cache == LockInfo(
|
|
332
|
+
pid=123,
|
|
333
|
+
hostname="foo",
|
|
334
|
+
user="user",
|
|
335
|
+
acquired_at=time_time,
|
|
336
|
+
expires_at=time_time + DEFAULT_LOCK_TIMEOUT,
|
|
337
|
+
)
|
|
338
|
+
mock_unlink.assert_called_once()
|
|
339
|
+
|
|
340
|
+
lock_info = lock._cache
|
|
341
|
+
temp_lockfile = (
|
|
342
|
+
fmu_dir.path / f".lock.{lock_info.hostname}.{lock_info.pid}.{temp_uuid.hex[:8]}"
|
|
343
|
+
)
|
|
344
|
+
# We mocked unlink(), so the temporary lockfile should still exist.
|
|
345
|
+
assert temp_lockfile.exists()
|
|
346
|
+
assert LockInfo.model_validate(json.loads(temp_lockfile.read_text())) == lock_info
|
|
347
|
+
|
|
348
|
+
assert lock.path.exists()
|
|
349
|
+
assert LockInfo.model_validate(json.loads(lock.path.read_text())) == lock_info
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def test_try_acquire_fails_when_writing_temp_file(
|
|
353
|
+
fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
|
|
354
|
+
) -> None:
|
|
355
|
+
"""Tests that try_acquire raises when failing to write the lock file.."""
|
|
356
|
+
lock = LockManager(fmu_dir)
|
|
357
|
+
with (
|
|
358
|
+
patch("os.write", side_effect=OSError("oops")),
|
|
359
|
+
patch("os.close") as mock_close,
|
|
360
|
+
patch("pathlib.Path.unlink") as mock_unlink,
|
|
361
|
+
pytest.raises(OSError, match="oops"),
|
|
362
|
+
):
|
|
363
|
+
lock._try_acquire()
|
|
364
|
+
mock_close.assert_called_twice()
|
|
365
|
+
mock_unlink.assert_called_once()
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def test_try_acquire_fails_when_linking_temp_file(
|
|
369
|
+
fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
|
|
370
|
+
) -> None:
|
|
371
|
+
"""Tests that try_acquire raises when failing to link the lock file.."""
|
|
372
|
+
lock = LockManager(fmu_dir)
|
|
373
|
+
with (
|
|
374
|
+
patch("os.link") as mock_link,
|
|
375
|
+
patch("os.close", side_effect=FileExistsError) as mock_close,
|
|
376
|
+
patch("pathlib.Path.unlink") as mock_unlink,
|
|
377
|
+
pytest.raises(FileExistsError),
|
|
378
|
+
):
|
|
379
|
+
assert lock._try_acquire() is False
|
|
380
|
+
mock_link.assert_called_once_with(lock.path)
|
|
381
|
+
mock_close.assert_called_twice()
|
|
382
|
+
mock_unlink.assert_called_once()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def test_is_locked_expected(
|
|
386
|
+
fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
|
|
387
|
+
) -> None:
|
|
388
|
+
"""Tests is_locked under expected conditions."""
|
|
389
|
+
lock = LockManager(fmu_dir)
|
|
390
|
+
assert lock.is_locked() is False
|
|
391
|
+
lock.acquire()
|
|
392
|
+
assert lock.is_locked() is True
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def test_is_locked_unexpected_members(
|
|
396
|
+
fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
|
|
397
|
+
) -> None:
|
|
398
|
+
"""Tests is_locked under expected member conditions."""
|
|
399
|
+
lock = LockManager(fmu_dir)
|
|
400
|
+
assert lock.is_locked() is False
|
|
401
|
+
lock.acquire()
|
|
402
|
+
assert lock.is_locked() is True
|
|
403
|
+
lock_info = lock._cache
|
|
404
|
+
lock._cache = None
|
|
405
|
+
assert lock.is_locked() is False
|
|
406
|
+
lock._cache = lock_info
|
|
407
|
+
lock._acquired_at = None
|
|
408
|
+
assert lock.is_locked() is False
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@pytest.mark.parametrize(
|
|
412
|
+
"is_mine, is_stale, expected",
|
|
413
|
+
[
|
|
414
|
+
(True, True, False),
|
|
415
|
+
(True, False, True),
|
|
416
|
+
(False, True, False),
|
|
417
|
+
(False, False, False),
|
|
418
|
+
],
|
|
419
|
+
)
|
|
420
|
+
def test_is_locked_unexpected_methods(
|
|
421
|
+
fmu_dir: ProjectFMUDirectory,
|
|
422
|
+
monkeypatch: MonkeyPatch,
|
|
423
|
+
is_mine: bool,
|
|
424
|
+
is_stale: bool,
|
|
425
|
+
expected: bool,
|
|
426
|
+
) -> None:
|
|
427
|
+
"""Tests is_locked under expected method conditions."""
|
|
428
|
+
lock = LockManager(fmu_dir)
|
|
429
|
+
assert lock.is_locked() is False
|
|
430
|
+
lock.acquire()
|
|
431
|
+
|
|
432
|
+
with (
|
|
433
|
+
patch.object(lock, "_is_mine", return_value=is_mine),
|
|
434
|
+
patch.object(lock, "_is_stale", return_value=is_stale),
|
|
435
|
+
):
|
|
436
|
+
assert lock.is_locked() is expected
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def test_refresh_works_as_expected(
|
|
440
|
+
fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
|
|
441
|
+
) -> None:
|
|
442
|
+
"""Tests refresh works as expected."""
|
|
443
|
+
lock = LockManager(fmu_dir)
|
|
444
|
+
|
|
445
|
+
start_time = 1234.5
|
|
446
|
+
refresh_time = 2345.6
|
|
447
|
+
with patch("time.time", return_value=start_time):
|
|
448
|
+
lock.acquire()
|
|
449
|
+
assert lock._cache is not None
|
|
450
|
+
assert lock._cache.expires_at == start_time + DEFAULT_LOCK_TIMEOUT
|
|
451
|
+
|
|
452
|
+
with patch("time.time", return_value=refresh_time):
|
|
453
|
+
lock.refresh()
|
|
454
|
+
assert lock._acquired_at == start_time
|
|
455
|
+
assert lock._cache.expires_at == refresh_time + DEFAULT_LOCK_TIMEOUT
|
|
456
|
+
|
|
457
|
+
lock_info = LockInfo.model_validate(json.loads(lock.path.read_text()))
|
|
458
|
+
assert lock_info.expires_at == refresh_time + DEFAULT_LOCK_TIMEOUT
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def test_refresh_without_lock_file(
|
|
462
|
+
fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
|
|
463
|
+
) -> None:
|
|
464
|
+
"""Tests refresh when lock file is not present."""
|
|
465
|
+
lock = LockManager(fmu_dir)
|
|
466
|
+
with pytest.raises(LockError, match="does not exist"):
|
|
467
|
+
lock.refresh()
|
|
468
|
+
|
|
469
|
+
lock.acquire()
|
|
470
|
+
lock.path.unlink() # It was deleted or something.
|
|
471
|
+
with pytest.raises(LockError, match="does not exist"):
|
|
472
|
+
lock.refresh()
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def test_refresh_without_owning_lock(
|
|
476
|
+
fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch
|
|
477
|
+
) -> None:
|
|
478
|
+
"""Tests refresh when lock file is not owned by the process."""
|
|
479
|
+
lock = LockManager(fmu_dir)
|
|
480
|
+
with patch("os.getpid", return_value=-1234):
|
|
481
|
+
lock.acquire()
|
|
482
|
+
with pytest.raises(LockError, match="held by another process"):
|
|
483
|
+
lock.refresh()
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def test_lock_release_unlinks_lock_file(fmu_dir: ProjectFMUDirectory) -> None:
|
|
487
|
+
"""Tests that releasing a lock removes the lock file."""
|
|
488
|
+
lock = LockManager(fmu_dir)
|
|
489
|
+
|
|
490
|
+
assert lock.exists is False
|
|
491
|
+
lock.acquire()
|
|
492
|
+
assert lock.exists
|
|
493
|
+
lock.release()
|
|
494
|
+
assert lock.exists is False
|
|
495
|
+
assert lock._cache is None
|
|
496
|
+
assert lock.path.exists() is False
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def test_safe_load(fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch) -> None:
|
|
500
|
+
"""Tests the safe load helper method."""
|
|
501
|
+
lock = LockManager(fmu_dir)
|
|
502
|
+
lock.acquire()
|
|
503
|
+
assert lock._cache is not None
|
|
504
|
+
assert lock._safe_load() == lock._cache
|
|
505
|
+
|
|
506
|
+
lock.release()
|
|
507
|
+
lock.path.write_text("a")
|
|
508
|
+
assert lock._safe_load() is None
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def test_save_expected(fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch) -> None:
|
|
512
|
+
"""Tests that save works as expected."""
|
|
513
|
+
lock = LockManager(fmu_dir)
|
|
514
|
+
lock.acquire()
|
|
515
|
+
|
|
516
|
+
lock_info = lock.load()
|
|
517
|
+
new_expires_at = 123.4
|
|
518
|
+
lock_info.expires_at = new_expires_at
|
|
519
|
+
|
|
520
|
+
temp_uuid = uuid.uuid4()
|
|
521
|
+
with patch("uuid.uuid4", return_value=temp_uuid):
|
|
522
|
+
lock.save(lock_info)
|
|
523
|
+
|
|
524
|
+
lock_info = lock.load()
|
|
525
|
+
assert lock_info.expires_at == new_expires_at
|
|
526
|
+
assert Path(f"{lock.path}.tmp.{temp_uuid.hex[:8]}").exists() is False
|
|
527
|
+
assert LockInfo.model_validate(json.loads(lock.path.read_text())) == lock_info
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def test_save_raises(fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch) -> None:
|
|
531
|
+
"""Tests that save fails as expected under failing conditions."""
|
|
532
|
+
lock = LockManager(fmu_dir)
|
|
533
|
+
lock.acquire()
|
|
534
|
+
|
|
535
|
+
lock_info = lock.load()
|
|
536
|
+
with (
|
|
537
|
+
patch("pathlib.Path.replace", side_effect=OSError("oops")) as mock_replace,
|
|
538
|
+
pytest.raises(LockError, match="oops"),
|
|
539
|
+
):
|
|
540
|
+
lock.save(lock_info)
|
|
541
|
+
mock_replace.assert_called_once_with(lock.path)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def test_save_not_owned(fmu_dir: ProjectFMUDirectory, monkeypatch: MonkeyPatch) -> None:
|
|
545
|
+
"""Tests that save fails when lock is not owned by process."""
|
|
546
|
+
lock = LockManager(fmu_dir)
|
|
547
|
+
lock.acquire()
|
|
548
|
+
|
|
549
|
+
lock_info = lock.load()
|
|
550
|
+
with (
|
|
551
|
+
patch.object(lock, "_is_mine", return_value=False),
|
|
552
|
+
pytest.raises(LockError, match="lock file is held by another"),
|
|
553
|
+
):
|
|
554
|
+
lock.save(lock_info)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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,
|
|
File without changes
|
|
File without changes
|