fmu-settings 0.1.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

fmu/settings/__init__.py CHANGED
@@ -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
 
fmu/settings/_fmu_dir.py CHANGED
@@ -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:
fmu/settings/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.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
34
  __commit_id__ = commit_id = None
@@ -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
@@ -0,0 +1,23 @@
1
+ fmu/__init__.py,sha256=htx6HlMme77I6pZ8U256-2B2cMJuELsu3JN3YM2Efh4,144
2
+ fmu/settings/__init__.py,sha256=CkEE7al_uBCQO1lxBKN5LzyCwzzH5Aq6kkEIR7f-zTw,336
3
+ fmu/settings/_fmu_dir.py,sha256=Br_hcfAXshiuDyWqG_qx5VXFpsCBJO1XDMPzdxxesoE,10684
4
+ fmu/settings/_init.py,sha256=5CT7tV2XHz5wuLh97XozyLiKpwogrsfjpxm2dpn7KWE,4097
5
+ fmu/settings/_logging.py,sha256=nEdmZlNCBsB1GfDmFMKCjZmeuRp3CRlbz1EYUemc95Y,1104
6
+ fmu/settings/_version.py,sha256=5zTqm8rgXsWYBpB2M3Zw_K1D-aV8wP7NsBLrmMKkrAQ,704
7
+ fmu/settings/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ fmu/settings/types.py,sha256=aeXEsznBTT1YRRY_LSRqK1j2gmMmyLYYTGYl3a9fweU,513
9
+ fmu/settings/_resources/__init__.py,sha256=LHYR_F7lNGdv8N6R3cEwds5CJQpkOthXFqsEs24vgF8,118
10
+ fmu/settings/_resources/config_managers.py,sha256=IjOtS2lSU55GE_TWqHjbBPAzE8xQyVBvpHcfm0hTSnI,6822
11
+ fmu/settings/_resources/lock_manager.py,sha256=IS7V7W1kCD45v-0tnhZYw0GD1MLl_DatqOeF9xazllg,8838
12
+ fmu/settings/_resources/pydantic_resource_manager.py,sha256=9zFcOUJKeapxbYvgerf3t2_9pL5acFU2rknri67pXt0,3131
13
+ fmu/settings/models/__init__.py,sha256=lRlXgl55ba2upmDzdvzx8N30JMq2Osnm8aa_xxTZn8A,112
14
+ fmu/settings/models/_enums.py,sha256=SQUZ-2mQcTx4F0oefPFfuQzMKsKTSFSB-wq_CH7TBRE,734
15
+ fmu/settings/models/_mappings.py,sha256=Z4Ex7MtmajBr6FjaNzmwDRwtJlaZZ8YKh9NDmZHRKPI,2832
16
+ fmu/settings/models/lock_info.py,sha256=-oHDF9v9bDLCoFvEg4S6XXYLeo19zRAZ8HynCv75VWg,711
17
+ fmu/settings/models/project_config.py,sha256=pxb54JmpXNMVAFUu_yJ89dNrYEk6hrPuFfFUpf84Jh0,1099
18
+ fmu/settings/models/user_config.py,sha256=dWFTcZY6UnEgNTuGqB-izraJ657PecsW0e0Nt9GBDhI,2666
19
+ fmu_settings-0.3.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
20
+ fmu_settings-0.3.0.dist-info/METADATA,sha256=IQAnLz_3cQpmOvKXTb14y2V37Ozu9dHP3OxzgZPrv8Q,2024
21
+ fmu_settings-0.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
22
+ fmu_settings-0.3.0.dist-info/top_level.txt,sha256=Z-FIY3pxn0UK2Wxi9IJ7fKoLSraaxuNGi1eokiE0ShM,4
23
+ fmu_settings-0.3.0.dist-info/RECORD,,
@@ -1,20 +0,0 @@
1
- fmu/__init__.py,sha256=htx6HlMme77I6pZ8U256-2B2cMJuELsu3JN3YM2Efh4,144
2
- fmu/settings/__init__.py,sha256=x96dVVR-2n2lYD84LGbL7W8l3-r7W_0reUTKZlE7S34,331
3
- fmu/settings/_fmu_dir.py,sha256=-w3cB0_2WCKYkXTmoOQtZHI_fHfCDbnzEtTF_lcYod8,10572
4
- fmu/settings/_init.py,sha256=5CT7tV2XHz5wuLh97XozyLiKpwogrsfjpxm2dpn7KWE,4097
5
- fmu/settings/_logging.py,sha256=nEdmZlNCBsB1GfDmFMKCjZmeuRp3CRlbz1EYUemc95Y,1104
6
- fmu/settings/_version.py,sha256=5jwwVncvCiTnhOedfkzzxmxsggwmTBORdFL_4wq0ZeY,704
7
- fmu/settings/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- fmu/settings/types.py,sha256=aeXEsznBTT1YRRY_LSRqK1j2gmMmyLYYTGYl3a9fweU,513
9
- fmu/settings/models/__init__.py,sha256=lRlXgl55ba2upmDzdvzx8N30JMq2Osnm8aa_xxTZn8A,112
10
- fmu/settings/models/_enums.py,sha256=SQUZ-2mQcTx4F0oefPFfuQzMKsKTSFSB-wq_CH7TBRE,734
11
- fmu/settings/models/_mappings.py,sha256=Z4Ex7MtmajBr6FjaNzmwDRwtJlaZZ8YKh9NDmZHRKPI,2832
12
- fmu/settings/models/project_config.py,sha256=K7y4PZMuq5wD-0Br60xfffBN6QQUV8viKGjjOcCB-7M,1098
13
- fmu/settings/models/user_config.py,sha256=JhMeSmWcE4GrBRkM_D5QVnUbRKfVy_XakHeKqJrYxvE,2217
14
- fmu/settings/resources/config_managers.py,sha256=nWUTu5Fp82ap0v7EmM3i29UnBT21XNURPLxQioje1FI,6852
15
- fmu/settings/resources/managers.py,sha256=t4Rp6MOSIq85iKfDHZLJxeGRj8S3SKanWHWri0p9wV8,3161
16
- fmu_settings-0.1.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
17
- fmu_settings-0.1.0.dist-info/METADATA,sha256=p9Hj7jKoexgusl1ErS1FwngSUjYMY2P39dgejhxOz2M,2024
18
- fmu_settings-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
19
- fmu_settings-0.1.0.dist-info/top_level.txt,sha256=Z-FIY3pxn0UK2Wxi9IJ7fKoLSraaxuNGi1eokiE0ShM,4
20
- fmu_settings-0.1.0.dist-info/RECORD,,