diff --git a/src/filelock/__init__.py b/src/filelock/__init__.py index c89008c..f69b2ec 100644 --- a/src/filelock/__init__.py +++ b/src/filelock/__init__.py @@ -1,442 +1,37 @@ -# This is free and unencumbered software released into the public domain. -# -# Anyone is free to copy, modify, publish, use, compile, sell, or -# distribute this software, either in source code form or as a compiled -# binary, for any purpose, commercial or non-commercial, and by any -# means. -# -# In jurisdictions that recognize copyright laws, the author or authors -# of this software dedicate any and all copyright interest in the -# software to the public domain. We make this dedication for the benefit -# of the public at large and to the detriment of our heirs and -# successors. We intend this dedication to be an overt act of -# relinquishment in perpetuity of all present and future rights to this -# software under copyright law. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -# IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR -# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -# OTHER DEALINGS IN THE SOFTWARE. -# -# For more information, please refer to - """ A platform independent file lock that supports the with-statement. """ -# Modules -# ------------------------------------------------ -import logging -import os -import threading -import time - +import sys +import warnings + +from ._api import AcquireReturnProxy, BaseFileLock +from ._error import Timeout +from ._soft import SoftFileLock +from ._unix import UnixFileLock, has_fcntl +from ._windows import WindowsFileLock from .version import version as __version__ -try: - import warnings -except ImportError: - warnings = None - -try: - import msvcrt -except ImportError: - msvcrt = None - -try: - import fcntl -except ImportError: - fcntl = None - - -# Backward compatibility -# ------------------------------------------------ -try: - TimeoutError -except NameError: - TimeoutError = OSError - - -# Data -# ------------------------------------------------ -__all__ = ["Timeout", "BaseFileLock", "WindowsFileLock", "UnixFileLock", "SoftFileLock", "FileLock", "__version__"] -_logger = None - - -def logger(): - """Returns the logger instance used in this module.""" - global _logger - _logger = _logger or logging.getLogger(__name__) - return _logger - - -# Exceptions -# ------------------------------------------------ -class Timeout(TimeoutError): - """ - Raised when the lock could not be acquired in *timeout* - seconds. - """ - - def __init__(self, lock_file): - """ """ - #: The path of the file lock. - self.lock_file = lock_file - return None - - def __str__(self): - temp = "The file lock '{}' could not be acquired.".format(self.lock_file) - return temp - - -# Classes -# ------------------------------------------------ - -# This is a helper class which is returned by :meth:`BaseFileLock.acquire` -# and wraps the lock to make sure __enter__ is not called twice when entering -# the with statement. -# If we would simply return *self*, the lock would be acquired again -# in the *__enter__* method of the BaseFileLock, but not released again -# automatically. -# -# :seealso: issue #37 (memory leak) -class _Acquire_ReturnProxy(object): - def __init__(self, lock): - self.lock = lock - return None - - def __enter__(self): - return self.lock - - def __exit__(self, exc_type, exc_value, traceback): - self.lock.release() - return None - - -class BaseFileLock(object): - """ - Implements the base class of a file lock. - """ - - def __init__(self, lock_file, timeout=-1): - """ """ - # The path to the lock file. - self._lock_file = lock_file - - # The file descriptor for the *_lock_file* as it is returned by the - # os.open() function. - # This file lock is only NOT None, if the object currently holds the - # lock. - self._lock_file_fd = None - - # The default timeout value. - self.timeout = timeout - - # We use this lock primarily for the lock counter. - self._thread_lock = threading.Lock() - - # The lock counter is used for implementing the nested locking - # mechanism. Whenever the lock is acquired, the counter is increased and - # the lock is only released, when this value is 0 again. - self._lock_counter = 0 - return None - - @property - def lock_file(self): - """ - The path to the lock file. - """ - return self._lock_file - - @property - def timeout(self): - """ - You can set a default timeout for the filelock. It will be used as - fallback value in the acquire method, if no timeout value (*None*) is - given. - - If you want to disable the timeout, set it to a negative value. - - A timeout of 0 means, that there is exactly one attempt to acquire the - file lock. - - .. versionadded:: 2.0.0 - """ - return self._timeout - - @timeout.setter - def timeout(self, value): - """ """ - self._timeout = float(value) - return None - - # Platform dependent locking - # -------------------------------------------- - - def _acquire(self): - """ - Platform dependent. If the file lock could be - acquired, self._lock_file_fd holds the file descriptor - of the lock file. - """ - raise NotImplementedError() - - def _release(self): - """ - Releases the lock and sets self._lock_file_fd to None. - """ - raise NotImplementedError() - - # Platform independent methods - # -------------------------------------------- - - @property - def is_locked(self): - """ - True, if the object holds the file lock. - - .. versionchanged:: 2.0.0 - - This was previously a method and is now a property. - """ - return self._lock_file_fd is not None - - def acquire(self, timeout=None, poll_intervall=0.05): - """ - Acquires the file lock or fails with a :exc:`Timeout` error. - - .. code-block:: python - - # You can use this method in the context manager (recommended) - with lock.acquire(): - pass - - # Or use an equivalent try-finally construct: - lock.acquire() - try: - pass - finally: - lock.release() - - :arg float timeout: - The maximum time waited for the file lock. - If ``timeout < 0``, there is no timeout and this method will - block until the lock could be acquired. - If ``timeout`` is None, the default :attr:`~timeout` is used. - - :arg float poll_intervall: - We check once in *poll_intervall* seconds if we can acquire the - file lock. - - :raises Timeout: - if the lock could not be acquired in *timeout* seconds. - - .. versionchanged:: 2.0.0 - - This method returns now a *proxy* object instead of *self*, - so that it can be used in a with statement without side effects. - """ - # Use the default timeout, if no timeout is provided. - if timeout is None: - timeout = self.timeout - - # Increment the number right at the beginning. - # We can still undo it, if something fails. - with self._thread_lock: - self._lock_counter += 1 - - lock_id = id(self) - lock_filename = self._lock_file - start_time = time.time() - try: - while True: - with self._thread_lock: - if not self.is_locked: - logger().debug("Attempting to acquire lock %s on %s", lock_id, lock_filename) - self._acquire() - - if self.is_locked: - logger().info("Lock %s acquired on %s", lock_id, lock_filename) - break - elif timeout >= 0 and time.time() - start_time > timeout: - logger().debug("Timeout on acquiring lock %s on %s", lock_id, lock_filename) - raise Timeout(self._lock_file) - else: - logger().debug( - "Lock %s not acquired on %s, waiting %s seconds ...", lock_id, lock_filename, poll_intervall - ) - time.sleep(poll_intervall) - except: - # Something did go wrong, so decrement the counter. - with self._thread_lock: - self._lock_counter = max(0, self._lock_counter - 1) - - raise - return _Acquire_ReturnProxy(lock=self) - - def release(self, force=False): - """ - Releases the file lock. - - Please note, that the lock is only completly released, if the lock - counter is 0. - - Also note, that the lock file itself is not automatically deleted. - - :arg bool force: - If true, the lock counter is ignored and the lock is released in - every case. - """ - with self._thread_lock: - - if self.is_locked: - self._lock_counter -= 1 - - if self._lock_counter == 0 or force: - lock_id = id(self) - lock_filename = self._lock_file - - logger().debug("Attempting to release lock %s on %s", lock_id, lock_filename) - self._release() - self._lock_counter = 0 - logger().info("Lock %s released on %s", lock_id, lock_filename) - - return None - - def __enter__(self): - self.acquire() - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.release() - return None - - def __del__(self): - self.release(force=True) - return None - - -# Windows locking mechanism -# ~~~~~~~~~~~~~~~~~~~~~~~~~ - - -class WindowsFileLock(BaseFileLock): - """ - Uses the :func:`msvcrt.locking` function to hard lock the lock file on - windows systems. - """ - - def _acquire(self): - open_mode = os.O_RDWR | os.O_CREAT | os.O_TRUNC - - try: - fd = os.open(self._lock_file, open_mode) - except OSError: - pass - else: - try: - msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) - except (IOError, OSError): - os.close(fd) - else: - self._lock_file_fd = fd - return None - - def _release(self): - fd = self._lock_file_fd - self._lock_file_fd = None - msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) - os.close(fd) - - try: - os.remove(self._lock_file) - # Probably another instance of the application - # that acquired the file lock. - except OSError: - pass - return None - - -# Unix locking mechanism -# ~~~~~~~~~~~~~~~~~~~~~~ - - -class UnixFileLock(BaseFileLock): - """ - Uses the :func:`fcntl.flock` to hard lock the lock file on unix systems. - """ - - def _acquire(self): - open_mode = os.O_RDWR | os.O_CREAT | os.O_TRUNC - fd = os.open(self._lock_file, open_mode) - - try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - except (IOError, OSError): - os.close(fd) - else: - self._lock_file_fd = fd - return None - - def _release(self): - # Do not remove the lockfile: - # - # https://github.com/tox-dev/py-filelock/issues/31 - # https://stackoverflow.com/questions/17708885/flock-removing-locked-file-without-race-condition - fd = self._lock_file_fd - self._lock_file_fd = None - fcntl.flock(fd, fcntl.LOCK_UN) - os.close(fd) - return None - - -# Soft lock -# ~~~~~~~~~ - - -class SoftFileLock(BaseFileLock): - """ - Simply watches the existence of the lock file. - """ - - def _acquire(self): - open_mode = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_TRUNC - try: - fd = os.open(self._lock_file, open_mode) - except (IOError, OSError): - pass - else: - self._lock_file_fd = fd - return None - - def _release(self): - os.close(self._lock_file_fd) - self._lock_file_fd = None - - try: - os.remove(self._lock_file) - # The file is already deleted and that's what we want. - except OSError: - pass - return None - - -# Platform filelock -# ~~~~~~~~~~~~~~~~~ - -#: Alias for the lock, which should be used for the current platform. On -#: Windows, this is an alias for :class:`WindowsFileLock`, on Unix for -#: :class:`UnixFileLock` and otherwise for :class:`SoftFileLock`. +#: Alias for the lock, which should be used for the current platform. On Windows, this is an alias for +# :class:`WindowsFileLock`, on Unix for :class:`UnixFileLock` and otherwise for :class:`SoftFileLock`. FileLock = None -if msvcrt: +if sys.platform == "win32": FileLock = WindowsFileLock -elif fcntl: +elif has_fcntl: FileLock = UnixFileLock else: FileLock = SoftFileLock if warnings is not None: warnings.warn("only soft file lock is available") + +__all__ = [ + "Timeout", + "BaseFileLock", + "WindowsFileLock", + "UnixFileLock", + "SoftFileLock", + "FileLock", + "AcquireReturnProxy", + "__version__", +] diff --git a/src/filelock/_api.py b/src/filelock/_api.py new file mode 100644 index 0000000..393e382 --- /dev/null +++ b/src/filelock/_api.py @@ -0,0 +1,194 @@ +import logging +import time +from threading import Lock + +from ._error import Timeout + +_LOGGER = logging.getLogger(__name__) + + +# This is a helper class which is returned by :meth:`BaseFileLock.acquire` and wraps the lock to make sure __enter__ +# is not called twice when entering the with statement. If we would simply return *self*, the lock would be acquired +# again in the *__enter__* method of the BaseFileLock, but not released again automatically. issue #37 (memory leak) +class AcquireReturnProxy(object): + def __init__(self, lock): + self.lock = lock + + def __enter__(self): + return self.lock + + def __exit__(self, exc_type, exc_value, traceback): + self.lock.release() + + +class BaseFileLock(object): + """Implements the base class of a file lock.""" + + def __init__(self, lock_file, timeout=-1): + """ """ + # The path to the lock file. + self._lock_file = lock_file + + # The file descriptor for the *_lock_file* as it is returned by the os.open() function. + # This file lock is only NOT None, if the object currently holds the lock. + self._lock_file_fd = None + + # The default timeout value. + self.timeout = timeout + + # We use this lock primarily for the lock counter. + self._thread_lock = Lock() + + # The lock counter is used for implementing the nested locking mechanism. Whenever the lock is acquired, the + # counter is increased and the lock is only released, when this value is 0 again. + self._lock_counter = 0 + + @property + def lock_file(self): + """The path to the lock file.""" + return self._lock_file + + @property + def timeout(self): + """ + You can set a default timeout for the filelock. It will be used as fallback value in the acquire method, if no + timeout value (*None*) is given. If you want to disable the timeout, set it to a negative value. A timeout of + 0 means, that there is exactly one attempt to acquire the file lock. + + .. versionadded:: 2.0.0 + """ + return self._timeout + + @timeout.setter + def timeout(self, value): + """change the timeout parameter""" + self._timeout = float(value) + + def _acquire(self): + """If the file lock could be acquired, self._lock_file_fd holds the file descriptor of the lock file.""" + raise NotImplementedError + + def _release(self): + """Releases the lock and sets self._lock_file_fd to None.""" + raise NotImplementedError + + @property + def is_locked(self): + """True, if the object holds the file lock. + + .. versionchanged:: 2.0.0 + + This was previously a method and is now a property. + """ + return self._lock_file_fd is not None + + def acquire(self, timeout=None, poll_intervall=0.05): + """ + Acquires the file lock or fails with a :exc:`Timeout` error. + + .. code-block:: python + + # You can use this method in the context manager (recommended) + with lock.acquire(): + pass + + # Or use an equivalent try-finally construct: + lock.acquire() + try: + pass + finally: + lock.release() + + :arg float timeout: + The maximum time waited for the file lock. + If ``timeout < 0``, there is no timeout and this method will + block until the lock could be acquired. + If ``timeout`` is None, the default :attr:`~timeout` is used. + + :arg float poll_intervall: + We check once in *poll_intervall* seconds if we can acquire the + file lock. + + :raises Timeout: + if the lock could not be acquired in *timeout* seconds. + + .. versionchanged:: 2.0.0 + + This method returns now a *proxy* object instead of *self*, + so that it can be used in a with statement without side effects. + """ + # Use the default timeout, if no timeout is provided. + if timeout is None: + timeout = self.timeout + + # Increment the number right at the beginning. We can still undo it, if something fails. + with self._thread_lock: + self._lock_counter += 1 + + lock_id = id(self) + lock_filename = self._lock_file + start_time = time.time() + try: + while True: + with self._thread_lock: + if not self.is_locked: + _LOGGER.debug("Attempting to acquire lock %s on %s", lock_id, lock_filename) + self._acquire() + + if self.is_locked: + _LOGGER.info("Lock %s acquired on %s", lock_id, lock_filename) + break + elif 0 <= timeout < time.time() - start_time: + _LOGGER.debug("Timeout on acquiring lock %s on %s", lock_id, lock_filename) + raise Timeout(self._lock_file) + else: + msg = "Lock %s not acquired on %s, waiting %s seconds ..." + _LOGGER.debug(msg, lock_id, lock_filename, poll_intervall) + time.sleep(poll_intervall) + except BaseException: # Something did go wrong, so decrement the counter. + with self._thread_lock: + self._lock_counter = max(0, self._lock_counter - 1) + raise + return AcquireReturnProxy(lock=self) + + def release(self, force=False): + """ + Releases the file lock. + + Please note, that the lock is only completly released, if the lock + counter is 0. + + Also note, that the lock file itself is not automatically deleted. + + :arg bool force: + If true, the lock counter is ignored and the lock is released in + every case. + """ + with self._thread_lock: + + if self.is_locked: + self._lock_counter -= 1 + + if self._lock_counter == 0 or force: + lock_id, lock_filename = id(self), self._lock_file + + _LOGGER.debug("Attempting to release lock %s on %s", lock_id, lock_filename) + self._release() + self._lock_counter = 0 + _LOGGER.info("Lock %s released on %s", lock_id, lock_filename) + + def __enter__(self): + self.acquire() + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.release() + + def __del__(self): + self.release(force=True) + + +__all__ = [ + "BaseFileLock", + "AcquireReturnProxy", +] diff --git a/src/filelock/_error.py b/src/filelock/_error.py new file mode 100644 index 0000000..d4384ff --- /dev/null +++ b/src/filelock/_error.py @@ -0,0 +1,22 @@ +import sys + +if sys.version[0] == 3: + TimeoutError = TimeoutError +else: + TimeoutError = OSError + + +class Timeout(TimeoutError): + """Raised when the lock could not be acquired in *timeout* seconds.""" + + def __init__(self, lock_file): + #: The path of the file lock. + self.lock_file = lock_file + + def __str__(self): + return "The file lock '{}' could not be acquired.".format(self.lock_file) + + +__all__ = [ + "Timeout", +] diff --git a/src/filelock/_soft.py b/src/filelock/_soft.py new file mode 100644 index 0000000..1261391 --- /dev/null +++ b/src/filelock/_soft.py @@ -0,0 +1,30 @@ +import os + +from ._api import BaseFileLock + + +class SoftFileLock(BaseFileLock): + """Simply watches the existence of the lock file.""" + + def _acquire(self): + open_mode = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_TRUNC + try: + fd = os.open(self._lock_file, open_mode) + except OSError: + pass + else: + self._lock_file_fd = fd + + def _release(self): + os.close(self._lock_file_fd) + self._lock_file_fd = None + try: + os.remove(self._lock_file) + # The file is already deleted and that's what we want. + except OSError: + pass + + +__all__ = [ + "SoftFileLock", +] diff --git a/src/filelock/_unix.py b/src/filelock/_unix.py new file mode 100644 index 0000000..c8fc48e --- /dev/null +++ b/src/filelock/_unix.py @@ -0,0 +1,40 @@ +import os + +from ._api import BaseFileLock + +try: + import fcntl +except ImportError: + fcntl = None + +#: a flag to indicate if the fcntl API is available +has_fcntl = fcntl is not None + + +class UnixFileLock(BaseFileLock): + """Uses the :func:`fcntl.flock` to hard lock the lock file on unix systems.""" + + def _acquire(self): + open_mode = os.O_RDWR | os.O_CREAT | os.O_TRUNC + fd = os.open(self._lock_file, open_mode) + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except (OSError, IOError): + os.close(fd) + else: + self._lock_file_fd = fd + + def _release(self): + # Do not remove the lockfile: + # https://github.com/tox-dev/py-filelock/issues/31 + # https://stackoverflow.com/questions/17708885/flock-removing-locked-file-without-race-condition + fd = self._lock_file_fd + self._lock_file_fd = None + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + + +__all__ = [ + "has_fcntl", + "UnixFileLock", +] diff --git a/src/filelock/_windows.py b/src/filelock/_windows.py new file mode 100644 index 0000000..30b446e --- /dev/null +++ b/src/filelock/_windows.py @@ -0,0 +1,43 @@ +import os + +from ._api import BaseFileLock + +try: + import msvcrt +except ImportError: + msvcrt = None + + +class WindowsFileLock(BaseFileLock): + """Uses the :func:`msvcrt.locking` function to hard lock the lock file on windows systems.""" + + def _acquire(self): + open_mode = os.O_RDWR | os.O_CREAT | os.O_TRUNC + try: + fd = os.open(self._lock_file, open_mode) + except OSError: + pass + else: + try: + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + except (OSError, IOError): + os.close(fd) + else: + self._lock_file_fd = fd + + def _release(self): + fd = self._lock_file_fd + self._lock_file_fd = None + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + os.close(fd) + + try: + os.remove(self._lock_file) + # Probably another instance of the application hat acquired the file lock. + except OSError: + pass + + +__all__ = [ + "WindowsFileLock", +] diff --git a/tests/test_filelock.py b/tests/test_filelock.py index d379595..644cff9 100644 --- a/tests/test_filelock.py +++ b/tests/test_filelock.py @@ -182,32 +182,32 @@ def test_timeout(lock_type, tmp_path): def test_default_timeout(lock_type, tmp_path): # test if the default timeout parameter works lock_path = tmp_path / "a" - lock1, lock2 = lock_type(str(lock_path)), lock_type(str(lock_path), timeout=0.1) - assert lock2.timeout == 0.1 + lock_1, lock_2 = lock_type(str(lock_path)), lock_type(str(lock_path), timeout=0.1) + assert lock_2.timeout == 0.1 # acquire lock 1 - lock1.acquire() - assert lock1.is_locked - assert not lock2.is_locked + lock_1.acquire() + assert lock_1.is_locked + assert not lock_2.is_locked # try to acquire lock 2 with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): - lock2.acquire() - assert not lock2.is_locked - assert lock1.is_locked + lock_2.acquire() + assert not lock_2.is_locked + assert lock_1.is_locked - lock2.timeout = 0 - assert lock2.timeout == 0 + lock_2.timeout = 0 + assert lock_2.timeout == 0 with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): - lock2.acquire() - assert not lock2.is_locked - assert lock1.is_locked + lock_2.acquire() + assert not lock_2.is_locked + assert lock_1.is_locked # release lock 1 - lock1.release() - assert not lock1.is_locked - assert not lock2.is_locked + lock_1.release() + assert not lock_1.is_locked + assert not lock_2.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @@ -217,8 +217,8 @@ def test_context_release_on_exc(lock_type, tmp_path): lock = lock_type(str(lock_path)) try: - with lock as lock1: - assert lock is lock1 + with lock as lock_1: + assert lock is lock_1 assert lock.is_locked raise Exception except Exception: @@ -232,8 +232,8 @@ def test_acquire_release_on_exc(lock_type, tmp_path): lock = lock_type(str(lock_path)) try: - with lock.acquire() as lock1: - assert lock is lock1 + with lock.acquire() as lock_1: + assert lock is lock_1 assert lock.is_locked raise Exception except Exception: diff --git a/whitelist.txt b/whitelist.txt index 0c45994..cc48bb5 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -9,24 +9,17 @@ fmt intersphinx intervall lk -lock1 -lock2 lockfile -merchantability msvcrt nblck nitpicky -noninfringement param pygments rdwr -returnproxy -seealso skipif src tmp trunc typehints unlck -unlicense wronly