fmu-settings 0.1.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.

Files changed (43) hide show
  1. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/PKG-INFO +1 -1
  2. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/src/fmu/settings/__init__.py +1 -1
  3. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/src/fmu/settings/_fmu_dir.py +6 -3
  4. fmu_settings-0.3.0/src/fmu/settings/_resources/__init__.py +5 -0
  5. {fmu_settings-0.1.0/src/fmu/settings/resources → fmu_settings-0.3.0/src/fmu/settings/_resources}/config_managers.py +2 -3
  6. fmu_settings-0.3.0/src/fmu/settings/_resources/lock_manager.py +271 -0
  7. fmu_settings-0.1.0/src/fmu/settings/resources/managers.py → fmu_settings-0.3.0/src/fmu/settings/_resources/pydantic_resource_manager.py +2 -2
  8. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/src/fmu/settings/_version.py +3 -3
  9. fmu_settings-0.3.0/src/fmu/settings/models/lock_info.py +30 -0
  10. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/src/fmu/settings/models/project_config.py +1 -1
  11. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/src/fmu/settings/models/user_config.py +19 -6
  12. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/src/fmu_settings.egg-info/PKG-INFO +1 -1
  13. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/src/fmu_settings.egg-info/SOURCES.txt +6 -2
  14. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/tests/conftest.py +2 -2
  15. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/tests/test_fmu_dir.py +37 -6
  16. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/tests/test_init.py +1 -1
  17. fmu_settings-0.3.0/tests/test_resources/test_lock_manager.py +554 -0
  18. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/tests/test_resources/test_project_config.py +4 -4
  19. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/tests/test_resources/test_resource_managers.py +1 -1
  20. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/.coveragerc +0 -0
  21. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/.github/pull_request_template.md +0 -0
  22. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/.github/workflows/ci.yml +0 -0
  23. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/.github/workflows/codeql.yml +0 -0
  24. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/.github/workflows/publish.yml +0 -0
  25. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/.gitignore +0 -0
  26. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/CONTRIBUTING.md +0 -0
  27. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/LICENSE +0 -0
  28. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/README.md +0 -0
  29. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/SECURITY.md +0 -0
  30. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/pyproject.toml +0 -0
  31. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/setup.cfg +0 -0
  32. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/src/fmu/__init__.py +0 -0
  33. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/src/fmu/settings/_init.py +0 -0
  34. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/src/fmu/settings/_logging.py +0 -0
  35. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/src/fmu/settings/models/__init__.py +0 -0
  36. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/src/fmu/settings/models/_enums.py +0 -0
  37. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/src/fmu/settings/models/_mappings.py +0 -0
  38. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/src/fmu/settings/py.typed +0 -0
  39. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/src/fmu/settings/types.py +0 -0
  40. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/src/fmu_settings.egg-info/dependency_links.txt +0 -0
  41. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/src/fmu_settings.egg-info/requires.txt +0 -0
  42. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/src/fmu_settings.egg-info/top_level.txt +0 -0
  43. {fmu_settings-0.1.0 → fmu_settings-0.3.0}/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.1.0
3
+ Version: 0.3.0
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
 
@@ -4,12 +4,13 @@ from pathlib import Path
4
4
  from typing import Any, Final, Self, TypeAlias, cast
5
5
 
6
6
  from ._logging import null_logger
7
- from .models.project_config import ProjectConfig
8
- from .models.user_config import UserConfig
9
- from .resources.config_managers import (
7
+ from ._resources.config_managers import (
10
8
  ProjectConfigManager,
11
9
  UserConfigManager,
12
10
  )
11
+ from ._resources.lock_manager import LockManager
12
+ from .models.project_config import ProjectConfig
13
+ from .models.user_config import UserConfig
13
14
 
14
15
  logger: Final = null_logger(__name__)
15
16
 
@@ -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():
@@ -0,0 +1,5 @@
1
+ """Contains resources used in this package.
2
+
3
+ Some resources contained here may also be used
4
+ outside this package.
5
+ """
@@ -4,16 +4,15 @@ 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
- from .managers import PydanticResourceManager
15
+ from .pydantic_resource_manager import PydanticResourceManager
17
16
 
18
17
  if TYPE_CHECKING:
19
18
  # Avoid circular dependency for type hint in __init__ only
@@ -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()
@@ -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.1.0'
32
- __version_tuple__ = version_tuple = (0, 1, 0)
31
+ __version__ = version = '0.3.0'
32
+ __version_tuple__ = version_tuple = (0, 3, 0)
33
33
 
34
- __commit_id__ = commit_id = 'gc714eb4cf'
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,15 +5,20 @@ 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
- from pydantic import AwareDatetime, BaseModel, SecretStr, field_serializer
10
+ from pydantic import (
11
+ AwareDatetime,
12
+ BaseModel,
13
+ SecretStr,
14
+ field_serializer,
15
+ field_validator,
16
+ )
12
17
 
13
18
  from fmu.settings import __version__
14
- from fmu.settings.types import ResettableBaseModel, VersionStr # noqa TC001
19
+ from fmu.settings.types import ResettableBaseModel, VersionStr # noqa: TC001
15
20
 
16
- RecentDirectories = Annotated[set[Path], annotated_types.Len(0, 5)]
21
+ RecentProjectDirectories = Annotated[list[Path], annotated_types.Len(0, 5)]
17
22
 
18
23
 
19
24
  class UserAPIKeys(BaseModel):
@@ -38,7 +43,7 @@ class UserConfig(ResettableBaseModel):
38
43
  version: VersionStr
39
44
  created_at: AwareDatetime
40
45
  user_api_keys: UserAPIKeys
41
- recent_directories: RecentDirectories
46
+ recent_project_directories: RecentProjectDirectories
42
47
 
43
48
  @classmethod
44
49
  def reset(cls: type[Self]) -> Self:
@@ -47,9 +52,17 @@ class UserConfig(ResettableBaseModel):
47
52
  version=__version__,
48
53
  created_at=datetime.now(UTC),
49
54
  user_api_keys=UserAPIKeys(),
50
- recent_directories=set(),
55
+ recent_project_directories=[],
51
56
  )
52
57
 
58
+ @field_validator("recent_project_directories", mode="before")
59
+ @classmethod
60
+ def ensure_unique(cls, recent_projects: list[Path]) -> list[Path]:
61
+ """Ensures that recent_project_directories contains unique entries."""
62
+ if len(recent_projects) != len(set(recent_projects)):
63
+ raise ValueError("recent_project_directories must contain unique entries")
64
+ return recent_projects
65
+
53
66
  def obfuscate_secrets(self: Self) -> Self:
54
67
  """Returns a copy of the model with obfuscated secrets.
55
68
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fmu-settings
3
- Version: 0.1.0
3
+ Version: 0.3.0
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
@@ -17,13 +17,16 @@ src/fmu/settings/_logging.py
17
17
  src/fmu/settings/_version.py
18
18
  src/fmu/settings/py.typed
19
19
  src/fmu/settings/types.py
20
+ src/fmu/settings/_resources/__init__.py
21
+ src/fmu/settings/_resources/config_managers.py
22
+ src/fmu/settings/_resources/lock_manager.py
23
+ src/fmu/settings/_resources/pydantic_resource_manager.py
20
24
  src/fmu/settings/models/__init__.py
21
25
  src/fmu/settings/models/_enums.py
22
26
  src/fmu/settings/models/_mappings.py
27
+ src/fmu/settings/models/lock_info.py
23
28
  src/fmu/settings/models/project_config.py
24
29
  src/fmu/settings/models/user_config.py
25
- src/fmu/settings/resources/config_managers.py
26
- src/fmu/settings/resources/managers.py
27
30
  src/fmu_settings.egg-info/PKG-INFO
28
31
  src/fmu_settings.egg-info/SOURCES.txt
29
32
  src/fmu_settings.egg-info/dependency_links.txt
@@ -32,6 +35,7 @@ src/fmu_settings.egg-info/top_level.txt
32
35
  tests/conftest.py
33
36
  tests/test_fmu_dir.py
34
37
  tests/test_init.py
38
+ tests/test_resources/test_lock_manager.py
35
39
  tests/test_resources/test_project_config.py
36
40
  tests/test_resources/test_resource_managers.py
37
41
  tests/test_resources/test_user_config.py
@@ -126,7 +126,7 @@ def user_config_dict(unix_epoch_utc: datetime) -> dict[str, Any]:
126
126
  "user_api_keys": {
127
127
  "smda_subscription": None,
128
128
  },
129
- "recent_directories": [],
129
+ "recent_project_directories": [],
130
130
  }
131
131
 
132
132
 
@@ -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 (
@@ -288,30 +288,61 @@ def test_update_user_config(user_fmu_dir: UserFMUDirectory) -> None:
288
288
  """Tests update_config updates and saves the user config for multiple values."""
289
289
  recent_dir = "/foo/bar"
290
290
  updated_config = user_fmu_dir.update_config(
291
- {"version": "2.0.0", "recent_directories": [recent_dir]}
291
+ {"version": "2.0.0", "recent_project_directories": [recent_dir]}
292
292
  )
293
293
 
294
294
  assert updated_config.version == "2.0.0"
295
- assert updated_config.recent_directories == {Path(recent_dir)}
295
+ assert updated_config.recent_project_directories == [Path(recent_dir)]
296
296
 
297
297
  assert user_fmu_dir.config.load() is not None
298
298
  assert user_fmu_dir.get_config_value("version", None) == "2.0.0"
299
- assert user_fmu_dir.get_config_value("recent_directories") == {Path(recent_dir)}
299
+ assert user_fmu_dir.get_config_value("recent_project_directories") == [
300
+ Path(recent_dir)
301
+ ]
300
302
 
301
303
  config_file = user_fmu_dir.config.path
302
304
  with open(config_file, encoding="utf-8") as f:
303
305
  saved_config = json.load(f)
304
306
 
305
307
  assert saved_config["version"] == "2.0.0"
306
- assert saved_config["recent_directories"] == [recent_dir]
308
+ assert saved_config["recent_project_directories"] == [recent_dir]
307
309
 
308
310
 
309
311
  def test_update_user_config_invalid_data(user_fmu_dir: UserFMUDirectory) -> None:
310
312
  """Tests that update_config raises ValidationError on bad data."""
311
- updates = {"recent_directories": [123]}
313
+ updates = {"recent_project_directories": [123]}
312
314
  with pytest.raises(
313
315
  ValueError,
314
316
  match="Invalid value set for 'UserConfigManager' with updates "
315
- "'{'recent_directories':",
317
+ "'{'recent_project_directories':",
316
318
  ):
317
319
  user_fmu_dir.update_config(updates)
320
+
321
+
322
+ def test_update_user_config_non_unique_recent_projects(
323
+ user_fmu_dir: UserFMUDirectory,
324
+ ) -> None:
325
+ """Tests that update_config raises on non-unique recent_project_directories."""
326
+ updates = {"recent_project_directories": [Path("/foo/bar"), Path("/foo/bar")]}
327
+ with pytest.raises(ValueError, match="unique entries"):
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()
@@ -134,7 +134,7 @@ def test_init_user_fmu_directory(
134
134
  assert config_json["version"] == __version__
135
135
  assert config_json["created_at"] != str(unix_epoch_utc)
136
136
  assert config_json["user_api_keys"] == {"smda_subscription": None}
137
- assert config_json["recent_directories"] == []
137
+ assert config_json["recent_project_directories"] == []
138
138
 
139
139
  created_at = datetime.fromisoformat(config_json["created_at"])
140
140
  now = datetime.now(UTC)
@@ -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)
@@ -6,15 +6,15 @@ 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
- from fmu.settings.models.project_config import ProjectConfig
13
- from fmu.settings.models.user_config import UserConfig
14
- from fmu.settings.resources.config_managers import (
12
+ from fmu.settings._resources.config_managers import (
15
13
  ProjectConfigManager,
16
14
  UserConfigManager,
17
15
  )
16
+ from fmu.settings.models.project_config import ProjectConfig
17
+ from fmu.settings.models.user_config import UserConfig
18
18
 
19
19
 
20
20
  @pytest.fixture
@@ -8,7 +8,7 @@ import pytest
8
8
  from pydantic import BaseModel
9
9
 
10
10
  from fmu.settings._fmu_dir import ProjectFMUDirectory
11
- from fmu.settings.resources.managers import PydanticResourceManager
11
+ from fmu.settings._resources.pydantic_resource_manager import PydanticResourceManager
12
12
 
13
13
 
14
14
  class A(BaseModel):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes