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 |