""" 文件级并发控制 — 跨平台进程级文件锁。 Unix: fcntl.flock (POSIX) Windows: msvcrt.locking (Win32) 均不支持时: 原子文件创建 (O_CREAT | O_EXCL) """ from __future__ import annotations import os import time import sys from pathlib import Path def _lock_file_unix(fd: int) -> None: import fcntl fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) def _unlock_file_unix(fd: int) -> None: import fcntl fcntl.flock(fd, fcntl.LOCK_UN) def _lock_file_windows(fd: int) -> None: import msvcrt msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) def _unlock_file_windows(fd: int) -> None: import msvcrt msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) if sys.platform == "win32": try: import msvcrt _lock_fn = _lock_file_windows _unlock_fn = _unlock_file_windows except ImportError: _lock_fn = None _unlock_fn = None else: try: import fcntl # noqa: F401 _lock_fn = _lock_file_unix _unlock_fn = _unlock_file_unix except ImportError: _lock_fn = None _unlock_fn = None class FileLock: """跨平台进程级文件锁。 Unix: fcntl.flock(LOCK_EX | LOCK_NB) Windows: msvcrt.locking(LK_NBLCK) 降级方案: 原子文件创建 (O_CREAT | O_EXCL) """ def __init__(self, path: Path, timeout: float = 10.0): lock_suffix = ".lock" if not path.suffix.endswith(".lock") else "" self._path = path.with_suffix(path.suffix + lock_suffix) if lock_suffix else path self._timeout = timeout self._fd: int | None = None self._fallback = _lock_fn is None def __enter__(self) -> "FileLock": if self._fallback: self._acquire_fallback() else: self._acquire_native() return self def __exit__(self, *exc) -> None: if self._fallback: self._release_fallback() elif self._fd is not None: _unlock_fn(self._fd) os.close(self._fd) self._fd = None def _acquire_native(self) -> None: self._fd = os.open(str(self._path), os.O_CREAT | os.O_RDWR) deadline = time.monotonic() + self._timeout while True: try: _lock_fn(self._fd) return except (OSError, IOError): if time.monotonic() >= deadline: os.close(self._fd) self._fd = None raise TimeoutError(f"lock timeout after {self._timeout}s: {self._path}") time.sleep(0.1) def _acquire_fallback(self) -> None: self._path.parent.mkdir(parents=True, exist_ok=True) deadline = time.monotonic() + self._timeout while True: try: self._fd = os.open( str(self._path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, ) return except FileExistsError: if time.monotonic() >= deadline: raise TimeoutError(f"lock timeout after {self._timeout}s: {self._path}") time.sleep(0.1) def _release_fallback(self) -> None: if self._fd is not None: os.close(self._fd) self._fd = None try: self._path.unlink(missing_ok=True) except OSError: pass @property def path(self) -> Path: return self._path