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 CHANGED
@@ -18,6 +18,7 @@ from .ctx import *
18
18
  from .decorators import *
19
19
  from .image import *
20
20
  from .io import *
21
+ from .lock import *
21
22
  from .parallel import *
22
23
  from .print import *
23
24
  from .typing import *
stouputils/__init__.pyi CHANGED
@@ -8,6 +8,7 @@ from .ctx import *
8
8
  from .decorators import *
9
9
  from .image import *
10
10
  from .io import *
11
+ from .lock import *
11
12
  from .parallel import *
12
13
  from .print import *
13
14
  from .typing import *
@@ -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.split(".")[-1], pattern)
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 != 'nt' # Not Windows
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, default: True)
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, default: True)
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
+
@@ -0,0 +1,5 @@
1
+ from .base import *
2
+ from .queue import *
3
+ from .re_entrant import *
4
+ from .redis_fifo import *
5
+ from .shared import *
@@ -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
+