stouputils 1.16.3__py3-none-any.whl → 1.17.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.
- stouputils/__init__.py +1 -0
- stouputils/__init__.pyi +1 -0
- stouputils/all_doctests.py +1 -1
- stouputils/decorators.py +1 -1
- stouputils/image.py +1 -1
- stouputils/image.pyi +1 -1
- stouputils/io.py +1 -1
- stouputils/io.pyi +1 -1
- stouputils/lock/__init__.py +36 -0
- stouputils/lock/__init__.pyi +5 -0
- stouputils/lock/base.py +536 -0
- stouputils/lock/base.pyi +169 -0
- stouputils/lock/queue.py +377 -0
- stouputils/lock/queue.pyi +131 -0
- stouputils/lock/re_entrant.py +115 -0
- stouputils/lock/re_entrant.pyi +81 -0
- stouputils/lock/redis_fifo.py +299 -0
- stouputils/lock/redis_fifo.pyi +123 -0
- stouputils/lock/shared.py +30 -0
- stouputils/lock/shared.pyi +16 -0
- {stouputils-1.16.3.dist-info → stouputils-1.17.0.dist-info}/METADATA +3 -1
- {stouputils-1.16.3.dist-info → stouputils-1.17.0.dist-info}/RECORD +24 -12
- {stouputils-1.16.3.dist-info → stouputils-1.17.0.dist-info}/WHEEL +0 -0
- {stouputils-1.16.3.dist-info → stouputils-1.17.0.dist-info}/entry_points.txt +0 -0
stouputils/__init__.py
CHANGED
stouputils/__init__.pyi
CHANGED
stouputils/all_doctests.py
CHANGED
|
@@ -99,7 +99,7 @@ def launch_tests(root_dir: str, strict: bool = True, pattern: str = "*") -> int:
|
|
|
99
99
|
import fnmatch
|
|
100
100
|
modules_file_paths = [
|
|
101
101
|
path for path in modules_file_paths
|
|
102
|
-
if fnmatch.fnmatch(path
|
|
102
|
+
if fnmatch.fnmatch(path, pattern)
|
|
103
103
|
]
|
|
104
104
|
if not modules_file_paths:
|
|
105
105
|
raise ValueError(f"No modules matching pattern '{pattern}' found in '{relative_path(root_dir)}'")
|
stouputils/decorators.py
CHANGED
|
@@ -211,7 +211,7 @@ def timeout(
|
|
|
211
211
|
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
212
212
|
# Check if we can use signal-based timeout (Unix only)
|
|
213
213
|
import os
|
|
214
|
-
use_signal: bool = os.name !=
|
|
214
|
+
use_signal: bool = os.name != "nt" # Not Windows
|
|
215
215
|
|
|
216
216
|
if use_signal:
|
|
217
217
|
try:
|
stouputils/image.py
CHANGED
|
@@ -401,7 +401,7 @@ def numpy_to_obj(
|
|
|
401
401
|
|
|
402
402
|
# Apply marching cubes algorithm to extract mesh
|
|
403
403
|
verts, faces, _, _ = cast(
|
|
404
|
-
tuple[NDArray[np.floating], NDArray[np.integer], NDArray[np.floating], NDArray[np.floating]],
|
|
404
|
+
"tuple[NDArray[np.floating], NDArray[np.integer], NDArray[np.floating], NDArray[np.floating]]",
|
|
405
405
|
measure.marching_cubes(volume, level=threshold, step_size=step_size, allow_degenerate=False) # type: ignore
|
|
406
406
|
)
|
|
407
407
|
|
stouputils/image.pyi
CHANGED
|
@@ -3,7 +3,7 @@ from .io import super_open as super_open
|
|
|
3
3
|
from .print import debug as debug, info as info
|
|
4
4
|
from PIL import Image
|
|
5
5
|
from collections.abc import Callable
|
|
6
|
-
from numpy.typing import NDArray
|
|
6
|
+
from numpy.typing import NDArray as NDArray
|
|
7
7
|
from typing import Any, TypeVar
|
|
8
8
|
|
|
9
9
|
PIL_Image_or_NDArray = TypeVar('PIL_Image_or_NDArray', bound='Image.Image | NDArray[np.number]')
|
stouputils/io.py
CHANGED
|
@@ -338,7 +338,7 @@ def super_copy(src: str, dst: str, create_dir: bool = True, symlink: bool = Fals
|
|
|
338
338
|
src (str): The source path
|
|
339
339
|
dst (str): The destination path
|
|
340
340
|
create_dir (bool): Whether to create the directory if it doesn't exist (default: True)
|
|
341
|
-
symlink (bool): Whether to create a symlink instead of copying (Linux only
|
|
341
|
+
symlink (bool): Whether to create a symlink instead of copying (Linux only)
|
|
342
342
|
Returns:
|
|
343
343
|
str: The destination path
|
|
344
344
|
"""
|
stouputils/io.pyi
CHANGED
|
@@ -144,7 +144,7 @@ def super_copy(src: str, dst: str, create_dir: bool = True, symlink: bool = Fals
|
|
|
144
144
|
\t\tsrc (str): The source path
|
|
145
145
|
\t\tdst (str): The destination path
|
|
146
146
|
\t\tcreate_dir (bool): Whether to create the directory if it doesn't exist (default: True)
|
|
147
|
-
\t\tsymlink (bool): Whether to create a symlink instead of copying (Linux only
|
|
147
|
+
\t\tsymlink (bool): Whether to create a symlink instead of copying (Linux only)
|
|
148
148
|
\tReturns:
|
|
149
149
|
\t\tstr: The destination path
|
|
150
150
|
\t"""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
""" Inter-process locks implementing First-In-First-Out (FIFO).
|
|
2
|
+
|
|
3
|
+
Source:
|
|
4
|
+
- https://en.wikipedia.org/wiki/File_locking
|
|
5
|
+
- https://en.wikipedia.org/wiki/Starvation_%28computer_science%29
|
|
6
|
+
- https://en.wikipedia.org/wiki/FIFO_and_LIFO_accounting
|
|
7
|
+
|
|
8
|
+
Provides three classes:
|
|
9
|
+
|
|
10
|
+
- LockFifo: basic cross-process lock using filesystem (POSIX via fcntl, Windows via msvcrt).
|
|
11
|
+
- RLockFifo: reentrant per-(process,thread) lock built on top of LockFifo.
|
|
12
|
+
- RedisLockFifo: distributed lock using redis (optional dependency).
|
|
13
|
+
|
|
14
|
+
Usage
|
|
15
|
+
-----
|
|
16
|
+
>>> import stouputils as stp
|
|
17
|
+
>>> with stp.LockFifo("some_directory/my.lock", timeout=5):
|
|
18
|
+
... pass
|
|
19
|
+
|
|
20
|
+
>>> with stp.RLockFifo("some_directory/my_r.lock", timeout=5):
|
|
21
|
+
... pass
|
|
22
|
+
|
|
23
|
+
>>> def _redis_example():
|
|
24
|
+
... with stp.RedisLockFifo("my_redis_lock", timeout=5):
|
|
25
|
+
... pass
|
|
26
|
+
>>> import os
|
|
27
|
+
>>> if os.name != "nt":
|
|
28
|
+
... _redis_example()
|
|
29
|
+
"""
|
|
30
|
+
# Imports
|
|
31
|
+
from .base import *
|
|
32
|
+
from .queue import *
|
|
33
|
+
from .re_entrant import *
|
|
34
|
+
from .redis_fifo import *
|
|
35
|
+
from .shared import *
|
|
36
|
+
|
stouputils/lock/base.py
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
|
|
2
|
+
# Imports
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import time
|
|
7
|
+
from contextlib import AbstractContextManager
|
|
8
|
+
from typing import IO, Any
|
|
9
|
+
|
|
10
|
+
from .shared import LockError, LockTimeoutError, resolve_path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _lock_fd(fd: int, blocking: bool, timeout: float | None) -> None:
|
|
14
|
+
"""Try to acquire an exclusive lock on an open file descriptor.
|
|
15
|
+
|
|
16
|
+
This helper attempts POSIX `fcntl` first, then Windows `msvcrt`.
|
|
17
|
+
It raises BlockingIOError when the lock is busy, ImportError if neither
|
|
18
|
+
backend is available, or OSError for unexpected errors.
|
|
19
|
+
"""
|
|
20
|
+
# Try POSIX advisory locks
|
|
21
|
+
try:
|
|
22
|
+
import fcntl
|
|
23
|
+
flags: int = fcntl.LOCK_EX # type: ignore
|
|
24
|
+
if not blocking or timeout is not None:
|
|
25
|
+
flags |= fcntl.LOCK_NB # type: ignore
|
|
26
|
+
fcntl.flock(fd, flags) # type: ignore
|
|
27
|
+
return
|
|
28
|
+
except (ImportError, ModuleNotFoundError):
|
|
29
|
+
pass
|
|
30
|
+
except BlockingIOError:
|
|
31
|
+
raise
|
|
32
|
+
except OSError as exc:
|
|
33
|
+
# Translate common busy errors to BlockingIOError
|
|
34
|
+
if getattr(exc, "errno", None) in (errno.EACCES, errno.EAGAIN, errno.EDEADLK):
|
|
35
|
+
raise BlockingIOError from exc
|
|
36
|
+
raise
|
|
37
|
+
|
|
38
|
+
# Try Windows msvcrt locking
|
|
39
|
+
try:
|
|
40
|
+
import msvcrt
|
|
41
|
+
mode = msvcrt.LK_NBLCK if not blocking or timeout is not None else msvcrt.LK_LOCK # type: ignore
|
|
42
|
+
msvcrt.locking(fd, mode, 1) # type: ignore
|
|
43
|
+
return
|
|
44
|
+
except (ImportError, ModuleNotFoundError) as e:
|
|
45
|
+
raise ImportError("No supported file locking backend available") from e
|
|
46
|
+
except OSError as exc:
|
|
47
|
+
if getattr(exc, "errno", None) in (errno.EACCES, errno.EAGAIN, errno.EDEADLK):
|
|
48
|
+
raise BlockingIOError from exc
|
|
49
|
+
raise
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _unlock_fd(fd: int | None) -> None:
|
|
53
|
+
"""Unlock an open file descriptor using the available backend."""
|
|
54
|
+
if fd is None:
|
|
55
|
+
return
|
|
56
|
+
try:
|
|
57
|
+
import fcntl
|
|
58
|
+
fcntl.flock(fd, fcntl.LOCK_UN) # type: ignore
|
|
59
|
+
return
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
try:
|
|
63
|
+
import msvcrt
|
|
64
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) # type: ignore
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _remove_file_if_unlocked(path: str) -> None:
|
|
70
|
+
"""Attempt to remove a file only if we can confirm nobody holds the lock.
|
|
71
|
+
|
|
72
|
+
Uses a non-blocking lock test via fcntl or msvcrt. This is best-effort and
|
|
73
|
+
will not raise on failure.
|
|
74
|
+
"""
|
|
75
|
+
import os
|
|
76
|
+
try:
|
|
77
|
+
import fcntl
|
|
78
|
+
try:
|
|
79
|
+
fd = os.open(path, os.O_RDONLY)
|
|
80
|
+
except FileNotFoundError:
|
|
81
|
+
return
|
|
82
|
+
try:
|
|
83
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) # type: ignore
|
|
84
|
+
try:
|
|
85
|
+
os.close(fd)
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
try:
|
|
89
|
+
os.remove(path)
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
except (BlockingIOError, OSError):
|
|
93
|
+
try:
|
|
94
|
+
os.close(fd)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
except Exception:
|
|
98
|
+
try:
|
|
99
|
+
os.close(fd)
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
return
|
|
103
|
+
except Exception:
|
|
104
|
+
# Fall through to Windows style test
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
import msvcrt
|
|
109
|
+
try:
|
|
110
|
+
fd = os.open(path, os.O_RDONLY)
|
|
111
|
+
except FileNotFoundError:
|
|
112
|
+
return
|
|
113
|
+
try:
|
|
114
|
+
try:
|
|
115
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) # type: ignore
|
|
116
|
+
locked = True
|
|
117
|
+
except OSError:
|
|
118
|
+
locked = False
|
|
119
|
+
if locked:
|
|
120
|
+
try:
|
|
121
|
+
os.close(fd)
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
try:
|
|
125
|
+
os.remove(path)
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
else:
|
|
129
|
+
try:
|
|
130
|
+
os.close(fd)
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
except Exception:
|
|
134
|
+
try:
|
|
135
|
+
os.close(fd)
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _worker(lp: str, op: str, idx: int) -> None: # pyright: ignore[reportUnusedFunction]
|
|
143
|
+
""" Module-level helper used by doctests as a multiprocessing target. """
|
|
144
|
+
from stouputils.lock import LockFifo
|
|
145
|
+
with LockFifo(lp, timeout=2):
|
|
146
|
+
with open(op, "a") as f:
|
|
147
|
+
f.write(f"{idx}\n")
|
|
148
|
+
time.sleep(0.01)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _hold(path: str) -> None: # pyright: ignore[reportUnusedFunction]
|
|
152
|
+
""" Module-level helper used by doctests as a multiprocessing target.
|
|
153
|
+
|
|
154
|
+
This creates a small readiness marker file while holding the lock so
|
|
155
|
+
doctests can reliably detect when the child process has acquired it
|
|
156
|
+
(useful on Windows spawn semantics).
|
|
157
|
+
"""
|
|
158
|
+
import os
|
|
159
|
+
|
|
160
|
+
from stouputils.lock import LockFifo
|
|
161
|
+
ready = f"{path}.held"
|
|
162
|
+
try:
|
|
163
|
+
with LockFifo(path, timeout=2):
|
|
164
|
+
with open(ready, "w") as f:
|
|
165
|
+
f.write("1")
|
|
166
|
+
time.sleep(1)
|
|
167
|
+
finally:
|
|
168
|
+
try:
|
|
169
|
+
os.remove(ready)
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class LockFifo(AbstractContextManager["LockFifo"]):
|
|
175
|
+
""" A simple cross-platform inter-process lock backed by a file.
|
|
176
|
+
|
|
177
|
+
This implementation supports optional Fifo ordering via a small ticket queue
|
|
178
|
+
stored alongside the lock file. Fifo is enabled by default to avoid
|
|
179
|
+
starvation. Fifo behaviour is implemented with a small sequence file and
|
|
180
|
+
per-ticket files in ``<lockpath>.queue/``. On platforms without fcntl the
|
|
181
|
+
implementation falls back to a timestamp-based ticket.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
name (str): Lock filename or path. If a simple name is given,
|
|
185
|
+
it is created in the system temporary directory.
|
|
186
|
+
timeout (float | None): Seconds to wait for the lock. ``None`` means block indefinitely.
|
|
187
|
+
blocking (bool): Whether to block until acquired (subject to ``timeout``).
|
|
188
|
+
check_interval (float): Interval between lock attempts, in seconds.
|
|
189
|
+
fifo (bool): Whether to enforce Fifo ordering (default: True).
|
|
190
|
+
fifo_stale_timeout (float | None): Seconds after which a ticket is considered stale; if ``None`` the lock's ``timeout`` value will be used.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
LockTimeoutError: If the lock could not be acquired within the timeout (LockError & TimeoutError subclass)
|
|
194
|
+
LockError: On unexpected locking errors. (RunTimeError subclass)
|
|
195
|
+
|
|
196
|
+
Examples:
|
|
197
|
+
>>> # Basic context-manager usage (Fifo enabled by default)
|
|
198
|
+
>>> with LockFifo("my.lock", timeout=1):
|
|
199
|
+
... pass
|
|
200
|
+
|
|
201
|
+
>>> # Explicit acquire/release
|
|
202
|
+
>>> lock = LockFifo("my.lock", timeout=1)
|
|
203
|
+
>>> lock.acquire()
|
|
204
|
+
>>> lock.release()
|
|
205
|
+
|
|
206
|
+
>>> # Doctest: simple multi-process Fifo check (fast and deterministic)
|
|
207
|
+
>>> import tempfile, multiprocessing, time
|
|
208
|
+
>>> tmpdir = tempfile.mkdtemp()
|
|
209
|
+
>>> lockpath = tmpdir + "/t.lock"
|
|
210
|
+
>>> out = tmpdir + "/out.txt"
|
|
211
|
+
>>> # Worker function is module-level: `_worker`
|
|
212
|
+
>>> # (Defined at module scope so it can be pickled on Windows)
|
|
213
|
+
>>> procs = []
|
|
214
|
+
>>> for i in range(3):
|
|
215
|
+
... p = multiprocessing.Process(target=_worker, args=(lockpath, out, i))
|
|
216
|
+
... p.start(); procs.append(p); time.sleep(0.05)
|
|
217
|
+
>>> for p in procs: p.join(1)
|
|
218
|
+
>>> with open(out) as f: print([int(x) for x in f.read().splitlines()])
|
|
219
|
+
[0, 1, 2]
|
|
220
|
+
|
|
221
|
+
>>> # Doctest: cleanup of artifacts on close
|
|
222
|
+
>>> import tempfile, os
|
|
223
|
+
>>> tmp = tempfile.mkdtemp()
|
|
224
|
+
>>> p = tmp + "/tlock"
|
|
225
|
+
>>> l = LockFifo(p, timeout=1)
|
|
226
|
+
>>> l.acquire(); l.release(); l.close()
|
|
227
|
+
>>> import os
|
|
228
|
+
>>> # The lock file should not remain on any platform after close()
|
|
229
|
+
>>> assert not os.path.exists(p)
|
|
230
|
+
>>> assert not os.path.exists(p + ".queue")
|
|
231
|
+
|
|
232
|
+
>>> # Non-Fifo fast-path should not create a queue directory
|
|
233
|
+
>>> tmp2 = tempfile.mkdtemp()
|
|
234
|
+
>>> p2 = tmp2 + "/tlock2"
|
|
235
|
+
>>> l2 = LockFifo(p2, fifo=False, timeout=1)
|
|
236
|
+
>>> l2.acquire(); l2.release(); l2.close()
|
|
237
|
+
>>> os.path.exists(p2 + ".queue")
|
|
238
|
+
False
|
|
239
|
+
|
|
240
|
+
>>> # Attempting a non-blocking acquire while another process holds the lock raises LockTimeoutError
|
|
241
|
+
>>> import multiprocessing, time
|
|
242
|
+
>>> # Hold function is module-level: `_hold`
|
|
243
|
+
>>> # (Defined at module scope so it can be pickled on Windows)
|
|
244
|
+
>>> p = multiprocessing.Process(target=_hold, args=(p2,))
|
|
245
|
+
>>> p.start()
|
|
246
|
+
>>> import time, os
|
|
247
|
+
>>> deadline = time.time() + 1.0
|
|
248
|
+
>>> while not os.path.exists(p2 + ".held") and time.time() < deadline:
|
|
249
|
+
... time.sleep(0.01)
|
|
250
|
+
>>> l3 = LockFifo(p2, timeout=1)
|
|
251
|
+
>>> try:
|
|
252
|
+
... l3.acquire(blocking=False)
|
|
253
|
+
... except LockTimeoutError:
|
|
254
|
+
... print("timeout")
|
|
255
|
+
... finally:
|
|
256
|
+
... p.terminate(); p.join()
|
|
257
|
+
timeout
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
def __init__(
|
|
261
|
+
self,
|
|
262
|
+
name: str,
|
|
263
|
+
timeout: float | None = None,
|
|
264
|
+
blocking: bool = True,
|
|
265
|
+
check_interval: float = 0.05,
|
|
266
|
+
fifo: bool = True,
|
|
267
|
+
fifo_stale_timeout: float | None = None
|
|
268
|
+
) -> None:
|
|
269
|
+
self.path: str = resolve_path(name)
|
|
270
|
+
""" The lock file path. """
|
|
271
|
+
self.timeout: float | None = timeout
|
|
272
|
+
""" Maximum time to wait for the lock, in seconds. None means wait indefinitely. """
|
|
273
|
+
self.blocking: bool = blocking
|
|
274
|
+
""" Whether to block until the lock is acquired (subject to ``timeout``). """
|
|
275
|
+
self.check_interval: float = check_interval
|
|
276
|
+
""" Interval between lock acquisition attempts, in seconds. """
|
|
277
|
+
self.file: IO[bytes] | None = None
|
|
278
|
+
""" The underlying file object. """
|
|
279
|
+
self.fd: int | None = None
|
|
280
|
+
""" The underlying file descriptor. """
|
|
281
|
+
self.is_locked: bool = False
|
|
282
|
+
""" Whether the lock is currently held. """
|
|
283
|
+
|
|
284
|
+
# Fifo queue configuration
|
|
285
|
+
self.fifo: bool = fifo
|
|
286
|
+
""" Whether Fifo ordering is enabled (default True). """
|
|
287
|
+
self.fifo_stale_timeout: float | None = fifo_stale_timeout
|
|
288
|
+
""" Seconds to consider a ticket stale and eligible for cleanup. If ``None``,
|
|
289
|
+
the lock's ``timeout`` value will be used; if that is also ``None``, no
|
|
290
|
+
stale cleanup will be performed. """
|
|
291
|
+
self.queue_dir: str = f"{self.path}.queue"
|
|
292
|
+
""" Directory used to store queue metadata and ticket files. """
|
|
293
|
+
try:
|
|
294
|
+
# Ensure queue directory exists early to avoid races on first get_ticket
|
|
295
|
+
if self.fifo:
|
|
296
|
+
import os as _os
|
|
297
|
+
_os.makedirs(self.queue_dir, exist_ok=True)
|
|
298
|
+
# Create a ticket queue backend instance
|
|
299
|
+
from .queue import FileTicketQueue
|
|
300
|
+
self.queue = FileTicketQueue(self.queue_dir, stale_timeout=self.fifo_stale_timeout if self.fifo_stale_timeout is not None else self.timeout)
|
|
301
|
+
else:
|
|
302
|
+
self.queue = None
|
|
303
|
+
except Exception:
|
|
304
|
+
# Swallow errors; queue is optional
|
|
305
|
+
self.queue = None
|
|
306
|
+
|
|
307
|
+
def _get_ticket(self) -> int:
|
|
308
|
+
""" Obtain a monotonically increasing ticket number.
|
|
309
|
+
|
|
310
|
+
Uses a small sequence file protected by an exclusive lock (fcntl) when
|
|
311
|
+
available. When fcntl is not available, falls back to a timestamp-based
|
|
312
|
+
ticket (still monotonic enough on typical systems).
|
|
313
|
+
"""
|
|
314
|
+
import os
|
|
315
|
+
import uuid
|
|
316
|
+
# Sequence file path
|
|
317
|
+
seq_path: str = os.path.join(self.queue_dir, "seq")
|
|
318
|
+
try:
|
|
319
|
+
# Prefer fcntl-based atomic increment on POSIX
|
|
320
|
+
import fcntl
|
|
321
|
+
# Ensure queue dir exists
|
|
322
|
+
os.makedirs(self.queue_dir, exist_ok=True)
|
|
323
|
+
with open(seq_path, "a+b") as f:
|
|
324
|
+
# Acquire exclusive lock while reading/updating sequence
|
|
325
|
+
fcntl.flock(f, fcntl.LOCK_EX) # type: ignore
|
|
326
|
+
f.seek(0)
|
|
327
|
+
data: str = f.read().decode().strip()
|
|
328
|
+
seq: int = int(data) if data else 0
|
|
329
|
+
seq += 1
|
|
330
|
+
f.seek(0)
|
|
331
|
+
f.truncate(0)
|
|
332
|
+
f.write(str(seq).encode())
|
|
333
|
+
f.flush()
|
|
334
|
+
fcntl.flock(f, fcntl.LOCK_UN) # type: ignore
|
|
335
|
+
return seq
|
|
336
|
+
except Exception:
|
|
337
|
+
# Fallback: timestamp + random suffix to reduce collisions
|
|
338
|
+
return int(time.time() * 1e6) * 1000000 + int(uuid.uuid4().hex[:6], 16)
|
|
339
|
+
|
|
340
|
+
def _cleanup_stale_tickets(self) -> None:
|
|
341
|
+
""" Remove stale ticket files from the queue directory.
|
|
342
|
+
|
|
343
|
+
A ticket is considered stale when its mtime is older than the effective
|
|
344
|
+
stale timeout. If ``self.fifo_stale_timeout`` is ``None``, the lock's
|
|
345
|
+
``timeout`` value is used; if that is also ``None``, no cleanup is
|
|
346
|
+
performed.
|
|
347
|
+
"""
|
|
348
|
+
if not self.fifo:
|
|
349
|
+
return
|
|
350
|
+
# Determine effective stale timeout (seconds)
|
|
351
|
+
stale: float | None = self.fifo_stale_timeout if self.fifo_stale_timeout is not None else self.timeout
|
|
352
|
+
if stale is None:
|
|
353
|
+
return
|
|
354
|
+
try:
|
|
355
|
+
import os
|
|
356
|
+
files: list[str] = sorted(os.listdir(self.queue_dir))
|
|
357
|
+
if not files:
|
|
358
|
+
return
|
|
359
|
+
head: str = files[0]
|
|
360
|
+
p: str = os.path.join(self.queue_dir, head)
|
|
361
|
+
try:
|
|
362
|
+
mtime: float = os.path.getmtime(p)
|
|
363
|
+
except FileNotFoundError:
|
|
364
|
+
return
|
|
365
|
+
# Use wall-clock epoch time to compare with mtime
|
|
366
|
+
age: float = time.time() - mtime
|
|
367
|
+
if age >= stale:
|
|
368
|
+
try:
|
|
369
|
+
os.remove(p)
|
|
370
|
+
except Exception:
|
|
371
|
+
pass
|
|
372
|
+
except Exception:
|
|
373
|
+
pass
|
|
374
|
+
|
|
375
|
+
def perform_lock(self, blocking: bool, timeout: float | None, check_interval: float) -> None:
|
|
376
|
+
""" Core platform-specific lock acquisition. This contains the original
|
|
377
|
+
flock-based implementation and is used both by Fifo and non-Fifo
|
|
378
|
+
paths.
|
|
379
|
+
"""
|
|
380
|
+
deadline: float | None = None if timeout is None else (time.monotonic() + timeout)
|
|
381
|
+
|
|
382
|
+
# Open file if not already opened
|
|
383
|
+
if self.fd is None:
|
|
384
|
+
self.file = open(self.path, "a+b")
|
|
385
|
+
self.fd = self.file.fileno()
|
|
386
|
+
|
|
387
|
+
# Main loop
|
|
388
|
+
while True:
|
|
389
|
+
blocked: bool = False
|
|
390
|
+
try:
|
|
391
|
+
_lock_fd(self.fd, blocking, timeout)
|
|
392
|
+
self.is_locked = True
|
|
393
|
+
return
|
|
394
|
+
except (ImportError, ModuleNotFoundError) as e:
|
|
395
|
+
raise LockError("Could not acquire lock: unsupported platform") from e
|
|
396
|
+
except BlockingIOError:
|
|
397
|
+
blocked = True
|
|
398
|
+
except OSError as exc:
|
|
399
|
+
if getattr(exc, "errno", None) in (errno.EACCES, errno.EAGAIN, errno.EDEADLK):
|
|
400
|
+
blocked = True
|
|
401
|
+
else:
|
|
402
|
+
raise LockError(str(exc)) from exc
|
|
403
|
+
|
|
404
|
+
if not blocked:
|
|
405
|
+
raise LockError("Could not acquire lock: unsupported platform")
|
|
406
|
+
|
|
407
|
+
# If we reach here, lock was busy
|
|
408
|
+
if not blocking:
|
|
409
|
+
raise LockTimeoutError("Lock is already held and blocking is False")
|
|
410
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
411
|
+
raise LockTimeoutError(f"Timeout while waiting for lock '{self.path}'")
|
|
412
|
+
time.sleep(check_interval)
|
|
413
|
+
|
|
414
|
+
def acquire(self, timeout: float | None = None, blocking: bool | None = None, check_interval: float | None = None) -> None:
|
|
415
|
+
""" Acquire the lock, optionally using Fifo ordering.
|
|
416
|
+
|
|
417
|
+
When Fifo is enabled (default), a ticket file is created and the caller
|
|
418
|
+
waits until its ticket becomes head of the queue before attempting the
|
|
419
|
+
actual underlying lock. This avoids starvation by ensuring waiters are
|
|
420
|
+
served in arrival order.
|
|
421
|
+
"""
|
|
422
|
+
# Use instance defaults if parameters not provided
|
|
423
|
+
if blocking is None:
|
|
424
|
+
blocking = self.blocking
|
|
425
|
+
if timeout is None:
|
|
426
|
+
timeout = self.timeout
|
|
427
|
+
if check_interval is None:
|
|
428
|
+
check_interval = self.check_interval
|
|
429
|
+
deadline: float | None = None if timeout is None else (time.monotonic() + timeout)
|
|
430
|
+
|
|
431
|
+
if not self.fifo or self.queue is None:
|
|
432
|
+
# Fast path: original behaviour
|
|
433
|
+
return self.perform_lock(blocking, timeout, check_interval)
|
|
434
|
+
|
|
435
|
+
# Fifo path using queue backend
|
|
436
|
+
ticket, member = self.queue.register()
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
while True:
|
|
440
|
+
# Cleanup stale head ticket if needed
|
|
441
|
+
self.queue.cleanup_stale()
|
|
442
|
+
if not self.queue.is_head(ticket):
|
|
443
|
+
if not blocking:
|
|
444
|
+
raise LockTimeoutError("Lock is already held and blocking is False")
|
|
445
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
446
|
+
raise LockTimeoutError(f"Timeout while waiting for lock '{self.path}'")
|
|
447
|
+
time.sleep(check_interval)
|
|
448
|
+
continue
|
|
449
|
+
# We're head of the queue; attempt to acquire underlying lock
|
|
450
|
+
self.perform_lock(blocking, timeout, check_interval)
|
|
451
|
+
# We obtained OS lock; remove ticket file (we hold the lock now)
|
|
452
|
+
try:
|
|
453
|
+
self.queue.remove(member)
|
|
454
|
+
except Exception:
|
|
455
|
+
pass
|
|
456
|
+
return
|
|
457
|
+
finally:
|
|
458
|
+
# Ensure our ticket is removed if we timed out or an unexpected error occurred
|
|
459
|
+
try:
|
|
460
|
+
self.queue.remove(member)
|
|
461
|
+
except Exception:
|
|
462
|
+
pass
|
|
463
|
+
|
|
464
|
+
def release(self) -> None:
|
|
465
|
+
""" Release the lock. """
|
|
466
|
+
if not self.is_locked:
|
|
467
|
+
return
|
|
468
|
+
try:
|
|
469
|
+
_unlock_fd(self.fd)
|
|
470
|
+
except Exception:
|
|
471
|
+
pass
|
|
472
|
+
|
|
473
|
+
# Ensure internal state is updated even if unlocking failed
|
|
474
|
+
self.is_locked = False
|
|
475
|
+
# Perform some cleanup of stale tickets
|
|
476
|
+
try:
|
|
477
|
+
self._cleanup_stale_tickets()
|
|
478
|
+
except Exception:
|
|
479
|
+
pass
|
|
480
|
+
# Keep file open for potential re-acquire; do not remove file
|
|
481
|
+
|
|
482
|
+
def __enter__(self) -> LockFifo:
|
|
483
|
+
self.acquire()
|
|
484
|
+
return self
|
|
485
|
+
|
|
486
|
+
def __exit__(self, exc_type: type | None, exc: BaseException | None, tb: Any | None) -> None:
|
|
487
|
+
self.release()
|
|
488
|
+
|
|
489
|
+
def close(self) -> None:
|
|
490
|
+
""" Release and close underlying file descriptor.
|
|
491
|
+
|
|
492
|
+
Also attempts best-effort cleanup of queue artifacts and the lock file
|
|
493
|
+
itself when it is safe to do so (no waiting clients and the lock is not
|
|
494
|
+
held). This avoids leaving behind ``<lock>.queue/`` and ``<lock>``
|
|
495
|
+
files when they are no longer in use.
|
|
496
|
+
"""
|
|
497
|
+
try:
|
|
498
|
+
self.release()
|
|
499
|
+
except Exception:
|
|
500
|
+
pass
|
|
501
|
+
finally:
|
|
502
|
+
if self.file is not None:
|
|
503
|
+
try:
|
|
504
|
+
self.file.close()
|
|
505
|
+
except Exception:
|
|
506
|
+
pass
|
|
507
|
+
self.file = None
|
|
508
|
+
self.fd = None
|
|
509
|
+
|
|
510
|
+
# Best-effort cleanup of queue artifacts
|
|
511
|
+
try:
|
|
512
|
+
if self.fifo and hasattr(self, "queue") and self.queue is not None:
|
|
513
|
+
try:
|
|
514
|
+
self.queue.cleanup_stale()
|
|
515
|
+
except Exception:
|
|
516
|
+
pass
|
|
517
|
+
try:
|
|
518
|
+
self.queue.maybe_cleanup()
|
|
519
|
+
except Exception:
|
|
520
|
+
pass
|
|
521
|
+
except Exception:
|
|
522
|
+
pass
|
|
523
|
+
|
|
524
|
+
# Try to remove the lock file itself when it is safe to do so. Best-effort.
|
|
525
|
+
try:
|
|
526
|
+
if not self.is_locked:
|
|
527
|
+
_remove_file_if_unlocked(self.path)
|
|
528
|
+
except Exception:
|
|
529
|
+
pass
|
|
530
|
+
|
|
531
|
+
def __del__(self) -> None:
|
|
532
|
+
self.close()
|
|
533
|
+
|
|
534
|
+
def __repr__(self) -> str:
|
|
535
|
+
return f"<LockFifo path={self.path!r} locked={self.is_locked}>"
|
|
536
|
+
|