pax_global_header00006660000000000000000000000064142301013360014503gustar00rootroot0000000000000052 comment=32248e25d9a92c76d14e7eb86098c2fdb6995f03 locket.py-1.0.0/000077500000000000000000000000001423010133600134115ustar00rootroot00000000000000locket.py-1.0.0/.github/000077500000000000000000000000001423010133600147515ustar00rootroot00000000000000locket.py-1.0.0/.github/workflows/000077500000000000000000000000001423010133600170065ustar00rootroot00000000000000locket.py-1.0.0/.github/workflows/tests.yml000066400000000000000000000020341423010133600206720ustar00rootroot00000000000000name: Tests on: [push, pull_request] jobs: build: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, windows-latest] python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, "3.10", pypy2, pypy3] exclude: - os: windows-latest python-version: 2.7 - os: windows-latest python-version: 3.5 - os: windows-latest python-version: 3.6 - os: windows-latest python-version: 3.7 - os: windows-latest python-version: pypy2 - os: windows-latest python-version: pypy3 steps: - uses: actions/checkout@v2 - name: Use Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - run: pip install tox - run: tox -e py locket.py-1.0.0/.gitignore000066400000000000000000000000741423010133600154020ustar00rootroot00000000000000/_virtualenv *.pyc /locket.egg-info /README /MANIFEST /.tox locket.py-1.0.0/LICENSE000066400000000000000000000024311423010133600144160ustar00rootroot00000000000000Copyright (c) 2012, Michael Williamson All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. locket.py-1.0.0/MANIFEST.in000066400000000000000000000000331423010133600151430ustar00rootroot00000000000000include LICENSE README.rst locket.py-1.0.0/README.rst000066400000000000000000000032601423010133600151010ustar00rootroot00000000000000locket.py: File-based locks for Python on Linux and Windows =========================================================== Locket implements a file-based lock that can be used by multiple processes provided they use the same path. .. code-block:: python import locket # Wait for lock with locket.lock_file("path/to/lock/file"): perform_action() # Raise LockError if lock cannot be acquired immediately with locket.lock_file("path/to/lock/file", timeout=0): perform_action() # Raise LockError if lock cannot be acquired after thirty seconds with locket.lock_file("path/to/lock/file", timeout=30): perform_action() # Without context managers: lock = locket.lock_file("path/to/lock/file") try: lock.acquire() perform_action() finally: lock.release() Locks largely behave as (non-reentrant) ``Lock`` instances from the ``threading`` module in the standard library. Specifically, their behaviour is: * Locks are uniquely identified by the file being locked, both in the same process and across different processes. * Locks are either in a locked or unlocked state. * When the lock is unlocked, calling ``acquire()`` returns immediately and changes the lock state to locked. * When the lock is locked, calling ``acquire()`` will block until the lock state changes to unlocked, or until the timeout expires. * If a process holds a lock, any thread in that process can call ``release()`` to change the state to unlocked. * Calling ``release()`` on an unlocked lock raises ``LockError``. * Behaviour of locks after ``fork`` is undefined. Installation ------------ .. code-block:: sh pip install locket locket.py-1.0.0/locket/000077500000000000000000000000001423010133600146725ustar00rootroot00000000000000locket.py-1.0.0/locket/__init__.py000066400000000000000000000127141423010133600170100ustar00rootroot00000000000000import errno import threading from time import sleep import weakref try: from time import monotonic as get_time except ImportError: from time import time as get_time __all__ = ["lock_file"] try: import fcntl except ImportError: try: import ctypes import ctypes.wintypes import msvcrt except ImportError: raise ImportError("Platform not supported (failed to import fcntl, ctypes, msvcrt)") else: _kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) _WinAPI_LockFile = _kernel32.LockFile _WinAPI_LockFile.restype = ctypes.wintypes.BOOL _WinAPI_LockFile.argtypes = [ctypes.wintypes.HANDLE] + [ctypes.wintypes.DWORD] * 4 _WinAPI_UnlockFile = _kernel32.UnlockFile _WinAPI_UnlockFile.restype = ctypes.wintypes.BOOL _WinAPI_UnlockFile.argtypes = [ctypes.wintypes.HANDLE] + [ctypes.wintypes.DWORD] * 4 _lock_file_blocking_available = False def _lock_file_non_blocking(file_): res = _WinAPI_LockFile(msvcrt.get_osfhandle(file_.fileno()), 0, 0, 1, 0) if res: return True else: err = ctypes.get_last_error() # 33 = ERROR_LOCK_VIOLATION if err != 33: raise ctypes.WinError(err) return False def _unlock_file(file_): _WinAPI_UnlockFile(msvcrt.get_osfhandle(file_.fileno()), 0, 0, 1, 0) else: _lock_file_blocking_available = True def _lock_file_blocking(file_): fcntl.flock(file_.fileno(), fcntl.LOCK_EX) def _lock_file_non_blocking(file_): try: fcntl.flock(file_.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) return True except IOError as error: if error.errno in [errno.EACCES, errno.EAGAIN]: return False else: raise def _unlock_file(file_): fcntl.flock(file_.fileno(), fcntl.LOCK_UN) _locks_lock = threading.Lock() _locks = weakref.WeakValueDictionary() def lock_file(path, **kwargs): _locks_lock.acquire() try: lock = _locks.get(path) if lock is None: lock = _create_lock_file(path) _locks[path] = lock finally: _locks_lock.release() return _Locker(lock, **kwargs) def _create_lock_file(path): thread_lock = _ThreadLock(path) file_lock = _LockFile(path) return _LockSet([thread_lock, file_lock]) class LockError(Exception): pass def _acquire_non_blocking(acquire, timeout, retry_period, path): if retry_period is None: retry_period = 0.05 start_time = get_time() while True: success = acquire() if success: return elif (timeout is not None and get_time() - start_time > timeout): raise LockError("Couldn't lock {0}".format(path)) else: sleep(retry_period) class _LockSet(object): def __init__(self, locks): self._locks = locks def acquire(self, timeout, retry_period): acquired_locks = [] try: for lock in self._locks: lock.acquire(timeout, retry_period) acquired_locks.append(lock) except: for acquired_lock in reversed(acquired_locks): # TODO: handle exceptions acquired_lock.release() raise def release(self): for lock in reversed(self._locks): # TODO: Handle exceptions lock.release() class _ThreadLock(object): def __init__(self, path): self._path = path self._lock = threading.Lock() def acquire(self, timeout=None, retry_period=None): if timeout is None: self._lock.acquire() else: _acquire_non_blocking( acquire=lambda: self._lock.acquire(False), timeout=timeout, retry_period=retry_period, path=self._path, ) def release(self): self._lock.release() class _LockFile(object): def __init__(self, path): self._path = path self._file = None def acquire(self, timeout=None, retry_period=None): fileobj = open(self._path, "wb") try: if timeout is None and _lock_file_blocking_available: _lock_file_blocking(fileobj) else: _acquire_non_blocking( acquire=lambda: _lock_file_non_blocking(fileobj), timeout=timeout, retry_period=retry_period, path=self._path, ) except: fileobj.close() raise else: self._file = fileobj def release(self): if self._file is None: raise LockError("cannot release unlocked lock") _unlock_file(self._file) self._file.close() self._file = None class _Locker(object): """ A lock wrapper to always apply the given *timeout* and *retry_period* to acquire() calls. """ def __init__(self, lock, timeout=None, retry_period=None): self._lock = lock self._timeout = timeout self._retry_period = retry_period def acquire(self): self._lock.acquire(self._timeout, self._retry_period) def release(self): self._lock.release() def __enter__(self): self.acquire() return self def __exit__(self, *args): self.release() locket.py-1.0.0/makefile000066400000000000000000000013131423010133600151070ustar00rootroot00000000000000.PHONY: test test: sh -c '. _virtualenv/bin/activate; py.test tests' .PHONY: upload upload: test build-dist _virtualenv/bin/twine upload dist/* make clean .PHONY: build-dist build-dist: clean _virtualenv/bin/pyproject-build .PHONY: clean clean: rm -f MANIFEST rm -rf build dist .PHONY: bootstrap bootstrap: _virtualenv _virtualenv/bin/pip install -e . ifneq ($(wildcard test-requirements.txt),) _virtualenv/bin/pip install -r test-requirements.txt endif make clean _virtualenv: python3 -m venv _virtualenv _virtualenv/bin/pip install --upgrade pip _virtualenv/bin/pip install --upgrade setuptools _virtualenv/bin/pip install --upgrade wheel _virtualenv/bin/pip install --upgrade build twine locket.py-1.0.0/setup.cfg000066400000000000000000000000341423010133600152270ustar00rootroot00000000000000[bdist_wheel] universal = 1 locket.py-1.0.0/setup.py000066400000000000000000000024021423010133600151210ustar00rootroot00000000000000#!/usr/bin/env python import os from setuptools import setup def read(fname): return open(os.path.join(os.path.dirname(__file__), fname)).read() setup( name='locket', version='1.0.0', description='File-based locks for Python on Linux and Windows', long_description=read("README.rst"), author='Michael Williamson', author_email='mike@zwobble.org', url='http://github.com/mwilliamson/locket.py', packages=['locket'], keywords="lock filelock lockfile process", python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', license="BSD-2-Clause", classifiers=[ 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Programming Language :: Python', 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Operating System :: Unix', 'Operating System :: Microsoft :: Windows', ], ) locket.py-1.0.0/test-requirements.txt000066400000000000000000000000361423010133600176510ustar00rootroot00000000000000pytest spur.local>=0.3.7,<0.4 locket.py-1.0.0/tests/000077500000000000000000000000001423010133600145535ustar00rootroot00000000000000locket.py-1.0.0/tests/__init__.py000066400000000000000000000000001423010133600166520ustar00rootroot00000000000000locket.py-1.0.0/tests/locker.py000066400000000000000000000014111423010133600164010ustar00rootroot00000000000000#!/usr/bin/env python from __future__ import print_function import signal import sys import os import locket def _print(output): print(output) sys.stdout.flush() if __name__ == "__main__": signal.signal(signal.SIGTERM, signal.SIG_DFL) print(os.getpid()) lock_path = sys.argv[1] if sys.argv[2] == "None": timeout = None else: timeout = float(sys.argv[2]) lock = locket.lock_file(lock_path, timeout=timeout) _print("Send newline to stdin to acquire") sys.stdin.readline() try: lock.acquire() except locket.LockError: _print("LockError") exit(1) _print("Acquired") _print("Send newline to stdin to release") sys.stdin.readline() lock.release() _print("Released") locket.py-1.0.0/tests/locket_tests.py000066400000000000000000000201541423010133600176320ustar00rootroot00000000000000import functools import os import io import sys import time import signal import pytest import spur import locket from .tempdir import create_temporary_dir local_shell = spur.LocalShell() @pytest.fixture(name="lock_path") def _fixture_lock_path(): with create_temporary_dir() as temp_dir: yield os.path.join(temp_dir, "some-lock") @pytest.fixture(name="spawn_locker") def _fixture_spawn_locker(): lockers = [] def spawn_locker(*args, **kwargs): locker = _Locker(*args, **kwargs) lockers.append(locker) return locker try: yield spawn_locker finally: for locker in lockers: locker.terminate() locker.wait() def test_single_process_can_obtain_uncontested_lock(lock_path): has_run = False with locket.lock_file(lock_path): has_run = True assert has_run def test_lock_can_be_acquired_with_timeout_of_zero(lock_path): has_run = False with locket.lock_file(lock_path, timeout=0): has_run = True assert has_run def test_lock_is_released_by_context_manager_exit(lock_path): has_run = False # Keep a reference to first_lock so it holds onto the lock first_lock = locket.lock_file(lock_path, timeout=0) with first_lock: pass with locket.lock_file(lock_path, timeout=0): has_run = True assert has_run def test_can_use_acquire_and_release_to_control_lock(lock_path): has_run = False lock = locket.lock_file(lock_path) lock.acquire() try: has_run = True finally: lock.release() assert has_run def test_thread_cannot_obtain_lock_using_same_object_twice_without_release(lock_path): with locket.lock_file(lock_path, timeout=0) as lock: try: lock.acquire() assert False, "Expected LockError" except locket.LockError: pass def test_thread_cannot_obtain_lock_using_same_path_twice_without_release(lock_path): with locket.lock_file(lock_path, timeout=0): lock = locket.lock_file(lock_path, timeout=0) try: lock.acquire() assert False, "Expected LockError" except locket.LockError: pass def test_thread_cannot_obtain_lock_using_same_path_with_different_arguments_without_release(lock_path): lock1 = locket.lock_file(lock_path, timeout=None) lock2 = locket.lock_file(lock_path, timeout=0) lock1.acquire() try: lock2.acquire() assert False, "Expected LockError" except locket.LockError: pass def test_calling_release_on_unlocked_lock_raises_lock_error(lock_path): lock = locket.lock_file(lock_path) try: lock.release() assert False, "Expected LockError" except locket.LockError as error: assert str(error) == "cannot release unlocked lock" def test_the_same_lock_file_object_is_used_for_the_same_path(lock_path): # We explicitly check the same lock is used to ensure that the lock isn't # re-entrant, even if the underlying platform lock is re-entrant. first_lock = locket.lock_file(lock_path, timeout=0) second_lock = locket.lock_file(lock_path, timeout=0) assert first_lock._lock is second_lock._lock def test_the_same_lock_file_object_is_used_for_the_same_path_with_different_arguments(lock_path): # We explicitly check the same lock is used to ensure that the lock isn't # re-entrant, even if the underlying platform lock is re-entrant. first_lock = locket.lock_file(lock_path, timeout=None) second_lock = locket.lock_file(lock_path, timeout=0) assert first_lock._lock is second_lock._lock def test_different_file_objects_are_used_for_different_paths(lock_path): first_lock = locket.lock_file(lock_path, timeout=0) second_lock = locket.lock_file(lock_path + "-2", timeout=0) assert first_lock._lock is not second_lock._lock def test_lock_file_blocks_until_lock_is_available(lock_path, spawn_locker): locker_1 = spawn_locker(lock_path) locker_2 = spawn_locker(lock_path) assert not locker_1.has_lock() assert not locker_2.has_lock() locker_1.acquire() time.sleep(0.1) locker_2.acquire() time.sleep(0.1) assert locker_1.has_lock() assert not locker_2.has_lock() locker_1.release() time.sleep(0.1) assert not locker_1.has_lock() assert locker_2.has_lock() locker_2.release() time.sleep(0.1) assert not locker_1.has_lock() assert not locker_2.has_lock() def test_lock_is_released_if_holding_process_is_brutally_killed(lock_path, spawn_locker): locker_1 = spawn_locker(lock_path) locker_2 = spawn_locker(lock_path) assert not locker_1.has_lock() assert not locker_2.has_lock() locker_1.acquire() time.sleep(0.1) locker_2.acquire() time.sleep(0.1) assert locker_1.has_lock() assert not locker_2.has_lock() locker_1.terminate() time.sleep(0.1) assert locker_2.has_lock() locker_2.release() def test_can_set_timeout_to_zero_to_raise_exception_if_lock_cannot_be_acquired(lock_path, spawn_locker): locker_1 = spawn_locker(lock_path) locker_2 = spawn_locker(lock_path, timeout=0) assert not locker_1.has_lock() assert not locker_2.has_lock() locker_1.acquire() time.sleep(0.1) locker_2.acquire() time.sleep(0.1) assert locker_1.has_lock() assert not locker_2.has_lock() locker_1.release() time.sleep(0.1) assert not locker_1.has_lock() assert not locker_2.has_lock() assert locker_2.has_error() def test_error_is_raised_after_timeout_has_expired(lock_path, spawn_locker): locker_1 = spawn_locker(lock_path) locker_2 = spawn_locker(lock_path, timeout=0.5) assert not locker_1.has_lock() assert not locker_2.has_lock() locker_1.acquire() time.sleep(0.1) locker_2.acquire() time.sleep(0.1) assert locker_1.has_lock() assert not locker_2.has_lock() assert not locker_2.has_error() time.sleep(1) assert locker_1.has_lock() assert not locker_2.has_lock() assert locker_2.has_error() locker_1.release() def test_lock_is_acquired_if_available_before_timeout_expires(lock_path, spawn_locker): locker_1 = spawn_locker(lock_path) locker_2 = spawn_locker(lock_path, timeout=2) assert not locker_1.has_lock() assert not locker_2.has_lock() locker_1.acquire() time.sleep(0.1) locker_2.acquire() time.sleep(0.1) assert locker_1.has_lock() assert not locker_2.has_lock() assert not locker_2.has_error() time.sleep(0.5) locker_1.release() time.sleep(0.1) assert not locker_1.has_lock() assert locker_2.has_lock() locker_2.release() class _Locker(object): def __init__(self, path, timeout=None): self._stdout = io.BytesIO() self._stderr = io.BytesIO() self._process = local_shell.spawn( [sys.executable, _locker_script_path, path, str(timeout)], stdout=self._stdout, stderr=self._stderr, allow_error=True, ) def acquire(self): self._process.stdin_write(b"\n") def release(self): self._process.stdin_write(b"\n") def wait_for_lock(self): start_time = time.time() while not self.has_lock(): if not self._process.is_running(): raise self._process.wait_for_result().to_error() time.sleep(0.1) if time.time() - start_time > 1: raise RuntimeError("Could not acquire lock, stdout:\n{0}".format(self._stdout.getvalue())) def has_lock(self): lines = self._stdout_lines() return "Acquired" in lines and "Released" not in lines def has_error(self): return "LockError" in self._stdout_lines() def terminate(self): pid = int(self._stdout_lines()[0].strip()) if self._process.is_running(): os.kill(pid, getattr(signal, "SIGKILL", signal.SIGTERM)) def wait(self): self._process.wait_for_result() def _stdout_lines(self): output = self._stdout.getvalue().decode("ascii") return [line.strip() for line in output.split("\n")] _locker_script_path = os.path.join(os.path.dirname(__file__), "locker.py") locket.py-1.0.0/tests/tempdir.py000066400000000000000000000003351423010133600165720ustar00rootroot00000000000000import contextlib import tempfile import shutil @contextlib.contextmanager def create_temporary_dir(): try: temp_dir = tempfile.mkdtemp() yield temp_dir finally: shutil.rmtree(temp_dir) locket.py-1.0.0/tox.ini000066400000000000000000000003251423010133600147240ustar00rootroot00000000000000[pytest] python_files = *_tests.py [tox] envlist = py27,py35,py36,py37,py38,py39,py310,pypy,pypy3 [testenv] changedir = {envtmpdir} deps=-r{toxinidir}/test-requirements.txt commands= py.test {toxinidir}/tests