Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/filelock/_api.py: 70%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

210 statements  

1from __future__ import annotations 

2 

3import contextlib 

4import inspect 

5import logging 

6import os 

7import pathlib 

8import sys 

9import time 

10import warnings 

11from abc import ABCMeta, abstractmethod 

12from dataclasses import dataclass 

13from threading import local 

14from typing import TYPE_CHECKING, Any, cast 

15from weakref import WeakValueDictionary 

16 

17from ._error import Timeout 

18 

19#: Sentinel indicating that no explicit file permission mode was passed. 

20#: When used, lock files are created with 0o666 (letting umask and default ACLs control the final permissions) 

21#: and fchmod is skipped so that POSIX default ACL inheritance is preserved. 

22_UNSET_FILE_MODE: int = -1 

23 

24if TYPE_CHECKING: 

25 from collections.abc import Callable 

26 from types import TracebackType 

27 

28 from ._read_write import ReadWriteLock 

29 

30 if sys.version_info >= (3, 11): # pragma: no cover (py311+) 

31 from typing import Self 

32 else: # pragma: no cover (<py311) 

33 from typing_extensions import Self 

34 

35 

36_LOGGER = logging.getLogger("filelock") 

37 

38# On Windows os.path.realpath calls CreateFileW with share_mode=0, which blocks concurrent DeleteFileW and causes 

39# livelocks under threaded contention with SoftFileLock. os.path.abspath is purely string-based and avoids this. 

40_canonical = os.path.abspath if sys.platform == "win32" else os.path.realpath 

41 

42 

43class _ThreadLocalRegistry(local): 

44 def __init__(self) -> None: 

45 super().__init__() 

46 self.held: dict[str, int] = {} 

47 

48 

49_registry = _ThreadLocalRegistry() 

50 

51 

52# This is a helper class which is returned by :meth:`BaseFileLock.acquire` and wraps the lock to make sure __enter__ 

53# is not called twice when entering the with statement. If we would simply return *self*, the lock would be acquired 

54# again in the *__enter__* method of the BaseFileLock, but not released again automatically. issue #37 (memory leak) 

55class AcquireReturnProxy: 

56 """A context-aware object that will release the lock file when exiting.""" 

57 

58 def __init__(self, lock: BaseFileLock | ReadWriteLock) -> None: 

59 self.lock: BaseFileLock | ReadWriteLock = lock 

60 

61 def __enter__(self) -> BaseFileLock | ReadWriteLock: 

62 return self.lock 

63 

64 def __exit__( 

65 self, 

66 exc_type: type[BaseException] | None, 

67 exc_value: BaseException | None, 

68 traceback: TracebackType | None, 

69 ) -> None: 

70 self.lock.release() 

71 

72 

73@dataclass 

74class FileLockContext: 

75 """A dataclass which holds the context for a ``BaseFileLock`` object.""" 

76 

77 # The context is held in a separate class to allow optional use of thread local storage via the 

78 # ThreadLocalFileContext class. 

79 

80 #: The path to the lock file. 

81 lock_file: str 

82 

83 #: The default timeout value. 

84 timeout: float 

85 

86 #: The mode for the lock files 

87 mode: int 

88 

89 #: Whether the lock should be blocking or not 

90 blocking: bool 

91 

92 #: The default polling interval value. 

93 poll_interval: float 

94 

95 #: The lock lifetime in seconds; ``None`` means the lock never expires. 

96 lifetime: float | None = None 

97 

98 #: The file descriptor for the *_lock_file* as it is returned by the os.open() function, not None when lock held 

99 lock_file_fd: int | None = None 

100 

101 #: The lock counter is used for implementing the nested locking mechanism. 

102 lock_counter: int = 0 # When the lock is acquired is increased and the lock is only released, when this value is 0 

103 

104 

105class ThreadLocalFileContext(FileLockContext, local): 

106 """A thread local version of the ``FileLockContext`` class.""" 

107 

108 

109class FileLockMeta(ABCMeta): 

110 _instances: WeakValueDictionary[str, BaseFileLock] 

111 

112 def __call__( # noqa: PLR0913 

113 cls, 

114 lock_file: str | os.PathLike[str], 

115 timeout: float = -1, 

116 mode: int = _UNSET_FILE_MODE, 

117 thread_local: bool = True, # noqa: FBT001, FBT002 

118 *, 

119 blocking: bool = True, 

120 is_singleton: bool = False, 

121 poll_interval: float = 0.05, 

122 lifetime: float | None = None, 

123 **kwargs: Any, # capture remaining kwargs for subclasses # noqa: ANN401 

124 ) -> BaseFileLock: 

125 if is_singleton: 

126 instance = cls._instances.get(str(lock_file)) 

127 if instance: 

128 params_to_check = { 

129 "thread_local": (thread_local, instance.is_thread_local()), 

130 "timeout": (timeout, instance.timeout), 

131 "mode": (mode, instance._context.mode), # noqa: SLF001 

132 "blocking": (blocking, instance.blocking), 

133 "poll_interval": (poll_interval, instance.poll_interval), 

134 "lifetime": (lifetime, instance.lifetime), 

135 } 

136 

137 non_matching_params = { 

138 name: (passed_param, set_param) 

139 for name, (passed_param, set_param) in params_to_check.items() 

140 if passed_param != set_param 

141 } 

142 if not non_matching_params: 

143 return cast("BaseFileLock", instance) 

144 

145 # parameters do not match; raise error 

146 msg = "Singleton lock instances cannot be initialized with differing arguments" 

147 msg += "\nNon-matching arguments: " 

148 for param_name, (passed_param, set_param) in non_matching_params.items(): 

149 msg += f"\n\t{param_name} (existing lock has {set_param} but {passed_param} was passed)" 

150 raise ValueError(msg) 

151 

152 # Workaround to make `__init__`'s params optional in subclasses 

153 # E.g. virtualenv changes the signature of the `__init__` method in the `BaseFileLock` class descendant 

154 # (https://github.com/tox-dev/filelock/pull/340) 

155 

156 all_params = { 

157 "timeout": timeout, 

158 "mode": mode, 

159 "thread_local": thread_local, 

160 "blocking": blocking, 

161 "is_singleton": is_singleton, 

162 "poll_interval": poll_interval, 

163 "lifetime": lifetime, 

164 **kwargs, 

165 } 

166 

167 present_params = inspect.signature(cls.__init__).parameters 

168 init_params = {key: value for key, value in all_params.items() if key in present_params} 

169 

170 instance = super().__call__(lock_file, **init_params) 

171 

172 if is_singleton: 

173 cls._instances[str(lock_file)] = instance 

174 

175 return cast("BaseFileLock", instance) 

176 

177 

178class BaseFileLock(contextlib.ContextDecorator, metaclass=FileLockMeta): 

179 """ 

180 Abstract base class for a file lock object. 

181 

182 Provides a reentrant, cross-process exclusive lock backed by OS-level primitives. Subclasses implement the actual 

183 locking mechanism (:class:`UnixFileLock <filelock.UnixFileLock>`, :class:`WindowsFileLock 

184 <filelock.WindowsFileLock>`, :class:`SoftFileLock <filelock.SoftFileLock>`). 

185 

186 """ 

187 

188 _instances: WeakValueDictionary[str, BaseFileLock] 

189 

190 def __init_subclass__(cls, **kwargs: dict[str, Any]) -> None: 

191 """Setup unique state for lock subclasses.""" 

192 super().__init_subclass__(**kwargs) 

193 cls._instances = WeakValueDictionary() 

194 

195 def __init__( # noqa: PLR0913 

196 self, 

197 lock_file: str | os.PathLike[str], 

198 timeout: float = -1, 

199 mode: int = _UNSET_FILE_MODE, 

200 thread_local: bool = True, # noqa: FBT001, FBT002 

201 *, 

202 blocking: bool = True, 

203 is_singleton: bool = False, 

204 poll_interval: float = 0.05, 

205 lifetime: float | None = None, 

206 ) -> None: 

207 """ 

208 Create a new lock object. 

209 

210 :param lock_file: path to the file 

211 :param timeout: default timeout when acquiring the lock, in seconds. It will be used as fallback value in the 

212 acquire method, if no timeout value (``None``) is given. If you want to disable the timeout, set it to a 

213 negative value. A timeout of 0 means that there is exactly one attempt to acquire the file lock. 

214 :param mode: file permissions for the lockfile. When not specified, the OS controls permissions via umask and 

215 default ACLs, preserving POSIX default ACL inheritance in shared directories. 

216 :param thread_local: Whether this object's internal context should be thread local or not. If this is set to 

217 ``False`` then the lock will be reentrant across threads. 

218 :param blocking: whether the lock should be blocking or not 

219 :param is_singleton: If this is set to ``True`` then only one instance of this class will be created per lock 

220 file. This is useful if you want to use the lock object for reentrant locking without needing to pass the 

221 same object around. 

222 :param poll_interval: default interval for polling the lock file, in seconds. It will be used as fallback value 

223 in the acquire method, if no poll_interval value (``None``) is given. 

224 :param lifetime: maximum time in seconds a lock can be held before it is considered expired. When set, a waiting 

225 process will break a lock whose file modification time is older than ``lifetime`` seconds. ``None`` (the 

226 default) means locks never expire. 

227 

228 """ 

229 self._is_thread_local = thread_local 

230 self._is_singleton = is_singleton 

231 

232 # Create the context. Note that external code should not work with the context directly and should instead use 

233 # properties of this class. 

234 kwargs: dict[str, Any] = { 

235 "lock_file": os.fspath(lock_file), 

236 "timeout": timeout, 

237 "mode": mode, 

238 "blocking": blocking, 

239 "poll_interval": poll_interval, 

240 "lifetime": lifetime, 

241 } 

242 self._context: FileLockContext = (ThreadLocalFileContext if thread_local else FileLockContext)(**kwargs) 

243 

244 def is_thread_local(self) -> bool: 

245 """:returns: a flag indicating if this lock is thread local or not""" 

246 return self._is_thread_local 

247 

248 @property 

249 def is_singleton(self) -> bool: 

250 """ 

251 :returns: a flag indicating if this lock is singleton or not 

252 

253 .. versionadded:: 3.13.0 

254 

255 """ 

256 return self._is_singleton 

257 

258 @property 

259 def lock_file(self) -> str: 

260 """:returns: path to the lock file""" 

261 return self._context.lock_file 

262 

263 @property 

264 def timeout(self) -> float: 

265 """ 

266 :returns: the default timeout value, in seconds 

267 

268 .. versionadded:: 2.0.0 

269 

270 """ 

271 return self._context.timeout 

272 

273 @timeout.setter 

274 def timeout(self, value: float | str) -> None: 

275 """ 

276 Change the default timeout value. 

277 

278 :param value: the new value, in seconds 

279 

280 """ 

281 self._context.timeout = float(value) 

282 

283 @property 

284 def blocking(self) -> bool: 

285 """ 

286 :returns: whether the locking is blocking or not 

287 

288 .. versionadded:: 3.14.0 

289 

290 """ 

291 return self._context.blocking 

292 

293 @blocking.setter 

294 def blocking(self, value: bool) -> None: 

295 """ 

296 Change the default blocking value. 

297 

298 :param value: the new value as bool 

299 

300 """ 

301 self._context.blocking = value 

302 

303 @property 

304 def poll_interval(self) -> float: 

305 """ 

306 :returns: the default polling interval, in seconds 

307 

308 .. versionadded:: 3.24.0 

309 

310 """ 

311 return self._context.poll_interval 

312 

313 @poll_interval.setter 

314 def poll_interval(self, value: float) -> None: 

315 """ 

316 Change the default polling interval. 

317 

318 :param value: the new value, in seconds 

319 

320 """ 

321 self._context.poll_interval = value 

322 

323 @property 

324 def lifetime(self) -> float | None: 

325 """ 

326 :returns: the lock lifetime in seconds, or ``None`` if the lock never expires 

327 

328 .. versionadded:: 3.24.0 

329 

330 """ 

331 return self._context.lifetime 

332 

333 @lifetime.setter 

334 def lifetime(self, value: float | None) -> None: 

335 """ 

336 Change the lock lifetime. 

337 

338 :param value: the new value in seconds, or ``None`` to disable expiration 

339 

340 """ 

341 self._context.lifetime = value 

342 

343 @property 

344 def mode(self) -> int: 

345 """:returns: the file permissions for the lockfile""" 

346 return 0o644 if self._context.mode == _UNSET_FILE_MODE else self._context.mode 

347 

348 @property 

349 def has_explicit_mode(self) -> bool: 

350 """:returns: whether the file permissions were explicitly set""" 

351 return self._context.mode != _UNSET_FILE_MODE 

352 

353 def _open_mode(self) -> int: 

354 """:returns: the mode for os.open() — 0o666 when unset (let umask/ACLs decide), else the explicit mode""" 

355 return 0o666 if self._context.mode == _UNSET_FILE_MODE else self._context.mode 

356 

357 def _try_break_expired_lock(self) -> None: 

358 """Remove the lock file if its modification time exceeds the configured :attr:`lifetime`.""" 

359 if (lifetime := self._context.lifetime) is None: 

360 return 

361 with contextlib.suppress(OSError): 

362 if time.time() - pathlib.Path(self.lock_file).stat().st_mtime < lifetime: 

363 return 

364 break_path = f"{self.lock_file}.break.{os.getpid()}" 

365 pathlib.Path(self.lock_file).rename(break_path) 

366 pathlib.Path(break_path).unlink() 

367 

368 @abstractmethod 

369 def _acquire(self) -> None: 

370 """If the file lock could be acquired, self._context.lock_file_fd holds the file descriptor of the lock file.""" 

371 raise NotImplementedError 

372 

373 @abstractmethod 

374 def _release(self) -> None: 

375 """Releases the lock and sets self._context.lock_file_fd to None.""" 

376 raise NotImplementedError 

377 

378 @property 

379 def is_locked(self) -> bool: 

380 """ 

381 :returns: A boolean indicating if the lock file is holding the lock currently. 

382 

383 .. versionchanged:: 2.0.0 

384 

385 This was previously a method and is now a property. 

386 

387 """ 

388 return self._context.lock_file_fd is not None 

389 

390 @property 

391 def lock_counter(self) -> int: 

392 """:returns: The number of times this lock has been acquired (but not yet released).""" 

393 return self._context.lock_counter 

394 

395 @staticmethod 

396 def _check_give_up( # noqa: PLR0913 

397 lock_id: int, 

398 lock_filename: str, 

399 *, 

400 blocking: bool, 

401 cancel_check: Callable[[], bool] | None, 

402 timeout: float, 

403 start_time: float, 

404 ) -> bool: 

405 if blocking is False: 

406 _LOGGER.debug("Failed to immediately acquire lock %s on %s", lock_id, lock_filename) 

407 return True 

408 if cancel_check is not None and cancel_check(): 

409 _LOGGER.debug("Cancellation requested for lock %s on %s", lock_id, lock_filename) 

410 return True 

411 if 0 <= timeout < time.perf_counter() - start_time: 

412 _LOGGER.debug("Timeout on acquiring lock %s on %s", lock_id, lock_filename) 

413 return True 

414 return False 

415 

416 def acquire( # noqa: C901 

417 self, 

418 timeout: float | None = None, 

419 poll_interval: float | None = None, 

420 *, 

421 poll_intervall: float | None = None, 

422 blocking: bool | None = None, 

423 cancel_check: Callable[[], bool] | None = None, 

424 ) -> AcquireReturnProxy: 

425 """ 

426 Try to acquire the file lock. 

427 

428 :param timeout: maximum wait time for acquiring the lock, ``None`` means use the default :attr:`~timeout` is and 

429 if ``timeout < 0``, there is no timeout and this method will block until the lock could be acquired 

430 :param poll_interval: interval of trying to acquire the lock file, ``None`` means use the default 

431 :attr:`~poll_interval` 

432 :param poll_intervall: deprecated, kept for backwards compatibility, use ``poll_interval`` instead 

433 :param blocking: defaults to True. If False, function will return immediately if it cannot obtain a lock on the 

434 first attempt. Otherwise, this method will block until the timeout expires or the lock is acquired. 

435 :param cancel_check: a callable returning ``True`` when the acquisition should be canceled. Checked on each poll 

436 iteration. When triggered, raises :class:`~Timeout` just like an expired timeout. 

437 

438 :returns: a context object that will unlock the file when the context is exited 

439 

440 :raises Timeout: if fails to acquire lock within the timeout period 

441 

442 .. code-block:: python 

443 

444 # You can use this method in the context manager (recommended) 

445 with lock.acquire(): 

446 pass 

447 

448 # Or use an equivalent try-finally construct: 

449 lock.acquire() 

450 try: 

451 pass 

452 finally: 

453 lock.release() 

454 

455 .. versionchanged:: 2.0.0 

456 

457 This method returns now a *proxy* object instead of *self*, so that it can be used in a with statement 

458 without side effects. 

459 

460 """ 

461 # Use the default timeout, if no timeout is provided. 

462 if timeout is None: 

463 timeout = self._context.timeout 

464 

465 if blocking is None: 

466 blocking = self._context.blocking 

467 

468 if poll_intervall is not None: 

469 msg = "use poll_interval instead of poll_intervall" 

470 warnings.warn(msg, DeprecationWarning, stacklevel=2) 

471 poll_interval = poll_intervall 

472 

473 poll_interval = poll_interval if poll_interval is not None else self._context.poll_interval 

474 

475 # Increment the number right at the beginning. We can still undo it, if something fails. 

476 self._context.lock_counter += 1 

477 

478 lock_id = id(self) 

479 lock_filename = self.lock_file 

480 canonical = _canonical(lock_filename) 

481 

482 would_block = self._context.lock_counter == 1 and not self.is_locked and timeout < 0 and blocking 

483 if would_block and (existing := _registry.held.get(canonical)) is not None and existing != lock_id: 

484 self._context.lock_counter -= 1 

485 msg = ( 

486 f"Deadlock: lock '{lock_filename}' is already held by a different " 

487 f"FileLock instance in this thread. Use is_singleton=True to " 

488 f"enable reentrant locking across instances." 

489 ) 

490 raise RuntimeError(msg) 

491 

492 start_time = time.perf_counter() 

493 try: 

494 while True: 

495 if not self.is_locked: 

496 self._try_break_expired_lock() 

497 _LOGGER.debug("Attempting to acquire lock %s on %s", lock_id, lock_filename) 

498 self._acquire() 

499 if self.is_locked: 

500 _LOGGER.debug("Lock %s acquired on %s", lock_id, lock_filename) 

501 break 

502 if self._check_give_up( 

503 lock_id, 

504 lock_filename, 

505 blocking=blocking, 

506 cancel_check=cancel_check, 

507 timeout=timeout, 

508 start_time=start_time, 

509 ): 

510 raise Timeout(lock_filename) # noqa: TRY301 

511 msg = "Lock %s not acquired on %s, waiting %s seconds ..." 

512 _LOGGER.debug(msg, lock_id, lock_filename, poll_interval) 

513 time.sleep(poll_interval) 

514 except BaseException: 

515 self._context.lock_counter = max(0, self._context.lock_counter - 1) 

516 if self._context.lock_counter == 0: 

517 _registry.held.pop(canonical, None) 

518 raise 

519 if self._context.lock_counter == 1: 

520 _registry.held[canonical] = lock_id 

521 return AcquireReturnProxy(lock=self) 

522 

523 def release(self, force: bool = False) -> None: # noqa: FBT001, FBT002 

524 """ 

525 Release the file lock. The lock is only completely released when the lock counter reaches 0. The lock file 

526 itself is not automatically deleted. 

527 

528 :param force: If true, the lock counter is ignored and the lock is released in every case. 

529 

530 """ 

531 if self.is_locked: 

532 self._context.lock_counter -= 1 

533 

534 if self._context.lock_counter == 0 or force: 

535 lock_id, lock_filename = id(self), self.lock_file 

536 

537 _LOGGER.debug("Attempting to release lock %s on %s", lock_id, lock_filename) 

538 self._release() 

539 self._context.lock_counter = 0 

540 _registry.held.pop(_canonical(lock_filename), None) 

541 _LOGGER.debug("Lock %s released on %s", lock_id, lock_filename) 

542 

543 def __enter__(self) -> Self: 

544 """ 

545 Acquire the lock. 

546 

547 :returns: the lock object 

548 

549 """ 

550 self.acquire() 

551 return self 

552 

553 def __exit__( 

554 self, 

555 exc_type: type[BaseException] | None, 

556 exc_value: BaseException | None, 

557 traceback: TracebackType | None, 

558 ) -> None: 

559 """ 

560 Release the lock. 

561 

562 :param exc_type: the exception type if raised 

563 :param exc_value: the exception value if raised 

564 :param traceback: the exception traceback if raised 

565 

566 """ 

567 self.release() 

568 

569 def __del__(self) -> None: 

570 """Called when the lock object is deleted.""" 

571 self.release(force=True) 

572 

573 

574__all__ = [ 

575 "_UNSET_FILE_MODE", 

576 "AcquireReturnProxy", 

577 "BaseFileLock", 

578]