Marco Ricci commited on 2025-04-12 20:01:40
Zeige 1 geänderte Dateien mit 128 Einfügungen und 39 Löschungen.
The class form is easier to monkeypatch than the generator form.
| ... | ... |
@@ -16,7 +16,6 @@ Warning: |
| 16 | 16 |
from __future__ import annotations |
| 17 | 17 |
|
| 18 | 18 |
import base64 |
| 19 |
-import contextlib |
|
| 20 | 19 |
import copy |
| 21 | 20 |
import enum |
| 22 | 21 |
import hashlib |
| ... | ... |
@@ -26,8 +25,9 @@ import os |
| 26 | 25 |
import pathlib |
| 27 | 26 |
import shlex |
| 28 | 27 |
import sys |
| 28 |
+import threading |
|
| 29 | 29 |
import unicodedata |
| 30 |
-from typing import TYPE_CHECKING, Callable, NoReturn, TextIO, cast |
|
| 30 |
+from typing import TYPE_CHECKING, cast |
|
| 31 | 31 |
|
| 32 | 32 |
import click |
| 33 | 33 |
import click.shell_completion |
| ... | ... |
@@ -43,13 +43,22 @@ else: |
| 43 | 43 |
|
| 44 | 44 |
if TYPE_CHECKING: |
| 45 | 45 |
import socket |
| 46 |
+ import types |
|
| 46 | 47 |
from collections.abc import ( |
| 47 | 48 |
Iterator, |
| 48 | 49 |
Mapping, |
| 49 | 50 |
Sequence, |
| 50 | 51 |
) |
| 52 |
+ from contextlib import AbstractContextManager |
|
| 53 |
+ from typing import ( |
|
| 54 |
+ BinaryIO, |
|
| 55 |
+ Callable, |
|
| 56 |
+ Literal, |
|
| 57 |
+ NoReturn, |
|
| 58 |
+ TextIO, |
|
| 59 |
+ ) |
|
| 51 | 60 |
|
| 52 |
- from typing_extensions import Buffer |
|
| 61 |
+ from typing_extensions import Buffer, Self |
|
| 53 | 62 |
|
| 54 | 63 |
PROG_NAME = _msg.PROG_NAME |
| 55 | 64 |
KEY_DISPLAY_LENGTH = 50 |
| ... | ... |
@@ -182,39 +191,52 @@ denote "any conceivable file size", the locking system available in |
| 182 | 191 |
""" |
| 183 | 192 |
|
| 184 | 193 |
|
| 185 |
-@contextlib.contextmanager |
|
| 186 |
-def configuration_mutex() -> Iterator[None]: |
|
| 187 |
- """Enter a mutually exclusive context for configuration writes. |
|
| 194 |
+class ConfigurationMutex: |
|
| 195 |
+ """A mutual exclusion context manager for configuration edits. |
|
| 188 | 196 |
|
| 189 |
- Within this context, no other cooperating instance of |
|
| 190 |
- `derivepassphrase` will attempt to write to its configuration |
|
| 191 |
- directory. We achieve this by locking a specific temporary file |
|
| 192 |
- (whose name depends on the location of the configuration directory) |
|
| 193 |
- for the duration of the context. |
|
| 197 |
+ See [`configuration_mutex`][]. |
|
| 194 | 198 |
|
| 195 |
- Note: Locking specifics |
|
| 196 |
- The directory for the lock file is determined via |
|
| 197 |
- [`get_tempdir`][]. The lock filename is |
|
| 198 |
- `derivepassphrase-lock-<hash>.txt`, where `<hash>` is computed |
|
| 199 |
- as follows. First, canonicalize the path to the configuration |
|
| 200 |
- directory with [`pathlib.Path.resolve`][]. Then encode the |
|
| 201 |
- result as per the filesystem encoding ([`os.fsencode`][]), and |
|
| 202 |
- hash it with SHA256. Finally, convert the result to standard |
|
| 203 |
- base32 and use the first twelve characters, in lowercase, as |
|
| 204 |
- `<hash>`. |
|
| 199 |
+ """ |
|
| 205 | 200 |
|
| 206 |
- We use [`msvcrt.locking`][] on Windows platforms (`sys.platform |
|
| 207 |
- == "win32"`) and [`fcntl.flock`][] on all others. All locks are |
|
| 208 |
- exclusive locks. If the locking system requires a byte range, |
|
| 209 |
- we lock the first [`LOCK_SIZE`][] bytes. For maximum |
|
| 210 |
- portability between locking implementations, we first open the |
|
| 211 |
- lock file for writing, which is sometimes necessary to lock |
|
| 212 |
- a file exclusively. Thus locking will fail if we lack |
|
| 213 |
- permission to write to an already-existing lockfile. |
|
| 201 |
+ lock: Callable[[int], None] |
|
| 202 |
+ """A function to lock a given file descriptor exclusively. |
|
| 203 |
+ |
|
| 204 |
+ On Windows, this uses [`msvcrt.locking`][], on other systems, this |
|
| 205 |
+ uses [`fcntl.flock`][]. |
|
| 206 |
+ |
|
| 207 |
+ Note: |
|
| 208 |
+ This is a normal Python function, not a method. |
|
| 209 |
+ |
|
| 210 |
+ Warning: |
|
| 211 |
+ You really should not have to change this. *If you absolutely |
|
| 212 |
+ must*, then it is *your responsibility* to ensure that |
|
| 213 |
+ [`lock`][] and [`unlock`][] are still compatible. |
|
| 214 | 214 |
|
| 215 | 215 |
""" |
| 216 |
- lock_func: Callable[[int], None] |
|
| 217 |
- unlock_func: Callable[[int], None] |
|
| 216 |
+ unlock: Callable[[int], None] |
|
| 217 |
+ """A function to unlock a given file descriptor. |
|
| 218 |
+ |
|
| 219 |
+ On Windows, this uses [`msvcrt.locking`][], on other systems, this |
|
| 220 |
+ uses [`fcntl.flock`][]. |
|
| 221 |
+ |
|
| 222 |
+ Note: |
|
| 223 |
+ This is a normal Python function, not a method. |
|
| 224 |
+ |
|
| 225 |
+ Warning: |
|
| 226 |
+ You really should not have to change this. *If you absolutely |
|
| 227 |
+ must*, then it is *your responsibility* to ensure that |
|
| 228 |
+ [`lock`][] and [`unlock`][] are still compatible. |
|
| 229 |
+ |
|
| 230 |
+ """ |
|
| 231 |
+ write_lock_file: pathlib.Path |
|
| 232 |
+ """The filename to lock.""" |
|
| 233 |
+ write_lock_fileobj: BinaryIO | None |
|
| 234 |
+ """The file object, if currently locked by this context manager.""" |
|
| 235 |
+ write_lock_condition: threading.Condition |
|
| 236 |
+ """The lock protecting access to the file object.""" |
|
| 237 |
+ |
|
| 238 |
+ def __init__(self) -> None: |
|
| 239 |
+ """Initialize self.""" |
|
| 218 | 240 |
if sys.platform == 'win32': # pragma: no cover |
| 219 | 241 |
import msvcrt # noqa: PLC0415 |
| 220 | 242 |
|
| ... | ... |
@@ -241,14 +263,81 @@ def configuration_mutex() -> Iterator[None]: |
| 241 | 263 |
def unlock_func(fileobj: int) -> None: |
| 242 | 264 |
flock(fileobj, LOCK_UN) |
| 243 | 265 |
|
| 244 |
- write_lock_file = config_filename('write lock')
|
|
| 245 |
- write_lock_file.touch() |
|
| 246 |
- with write_lock_file.open('wb') as lock_fileobj:
|
|
| 247 |
- lock_func(lock_fileobj.fileno()) |
|
| 248 |
- try: |
|
| 249 |
- yield |
|
| 250 |
- finally: |
|
| 251 |
- unlock_func(lock_fileobj.fileno()) |
|
| 266 |
+ self.lock = lock_func |
|
| 267 |
+ self.unlock = unlock_func |
|
| 268 |
+ self.write_lock_fileobj = None |
|
| 269 |
+ self.write_lock_file = config_filename('write lock')
|
|
| 270 |
+ self.write_lock_condition = threading.Condition(threading.Lock()) |
|
| 271 |
+ |
|
| 272 |
+ def __enter__(self) -> Self: |
|
| 273 |
+ """Enter the context, locking the configuration file.""" # noqa: DOC201 |
|
| 274 |
+ with self.write_lock_condition: |
|
| 275 |
+ self.write_lock_condition.wait_for( |
|
| 276 |
+ lambda: self.write_lock_fileobj is None |
|
| 277 |
+ ) |
|
| 278 |
+ self.write_lock_file.touch() |
|
| 279 |
+ self.write_lock_fileobj = self.write_lock_file.open('wb')
|
|
| 280 |
+ self.lock(self.write_lock_fileobj.fileno()) |
|
| 281 |
+ return self |
|
| 282 |
+ |
|
| 283 |
+ def __exit__( |
|
| 284 |
+ self, |
|
| 285 |
+ exc_type: type[BaseException] | None, |
|
| 286 |
+ exc_value: BaseException | None, |
|
| 287 |
+ exc_tb: types.TracebackType | None, |
|
| 288 |
+ /, |
|
| 289 |
+ ) -> Literal[False]: |
|
| 290 |
+ """Exit the context, releasing the lock on the configuration file.""" # noqa: DOC201 |
|
| 291 |
+ with self.write_lock_condition: |
|
| 292 |
+ assert self.write_lock_fileobj is not None, ( |
|
| 293 |
+ 'We lost track of the configuration write lock file object, ' |
|
| 294 |
+ 'so we cannot unlock it anymore!' |
|
| 295 |
+ ) |
|
| 296 |
+ self.unlock(self.write_lock_fileobj.fileno()) |
|
| 297 |
+ self.write_lock_fileobj.close() |
|
| 298 |
+ self.write_lock_fileobj = None |
|
| 299 |
+ return False |
|
| 300 |
+ |
|
| 301 |
+ |
|
| 302 |
+def configuration_mutex() -> AbstractContextManager[AbstractContextManager]: |
|
| 303 |
+ """Enter a mutually exclusive context for configuration writes. |
|
| 304 |
+ |
|
| 305 |
+ Within this context, no other cooperating instance of |
|
| 306 |
+ `derivepassphrase` will attempt to write to its configuration |
|
| 307 |
+ directory. We achieve this by locking a specific temporary file |
|
| 308 |
+ (whose name depends on the location of the configuration directory) |
|
| 309 |
+ for the duration of the context. |
|
| 310 |
+ |
|
| 311 |
+ Returns: |
|
| 312 |
+ A reusable but not reentrant context manager, ensuring mutual |
|
| 313 |
+ exclusion (while within its context) with all other |
|
| 314 |
+ `derivepassphrase` instances using the same configuration |
|
| 315 |
+ directory. |
|
| 316 |
+ |
|
| 317 |
+ Upon entering the context, the context manager returns itself. |
|
| 318 |
+ |
|
| 319 |
+ Note: Locking specifics |
|
| 320 |
+ The directory for the lock file is determined via |
|
| 321 |
+ [`get_tempdir`][]. The lock filename is |
|
| 322 |
+ `derivepassphrase-lock-<hash>.txt`, where `<hash>` is computed |
|
| 323 |
+ as follows. First, canonicalize the path to the configuration |
|
| 324 |
+ directory with [`pathlib.Path.resolve`][]. Then encode the |
|
| 325 |
+ result as per the filesystem encoding ([`os.fsencode`][]), and |
|
| 326 |
+ hash it with SHA256. Finally, convert the result to standard |
|
| 327 |
+ base32 and use the first twelve characters, in lowercase, as |
|
| 328 |
+ `<hash>`. |
|
| 329 |
+ |
|
| 330 |
+ We use [`msvcrt.locking`][] on Windows platforms (`sys.platform |
|
| 331 |
+ == "win32"`) and [`fcntl.flock`][] on all others. All locks are |
|
| 332 |
+ exclusive locks. If the locking system requires a byte range, |
|
| 333 |
+ we lock the first [`LOCK_SIZE`][] bytes. For maximum |
|
| 334 |
+ portability between locking implementations, we first open the |
|
| 335 |
+ lock file for writing, which is sometimes necessary to lock |
|
| 336 |
+ a file exclusively. Thus locking will fail if we lack |
|
| 337 |
+ permission to write to an already-existing lockfile. |
|
| 338 |
+ |
|
| 339 |
+ """ |
|
| 340 |
+ return ConfigurationMutex() |
|
| 252 | 341 |
|
| 253 | 342 |
|
| 254 | 343 |
def get_tempdir() -> pathlib.Path: |
| 255 | 344 |