diff --git a/docs/index.rst b/docs/index.rst index 1a7d72a..7995f76 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -89,18 +89,6 @@ already been done by other processes. For example, each process above first chec it is already created, we should not destroy the work of other processes. This is typically the case when we want just one process to write content into a file, and let every process to read the content. -The :meth:`acquire ` method accepts also a ``timeout`` parameter. If the lock cannot be -acquired within ``timeout`` seconds, a :class:`Timeout ` exception is raised: - -.. code-block:: python - - try: - with lock.acquire(timeout=10): - with open(file_path, "a") as f: - f.write("I have a bad feeling about this.") - except Timeout: - print("Another instance of this application currently holds the lock.") - The lock objects are recursive locks, which means that once acquired, they will not block on successive lock requests: .. code-block:: python @@ -124,6 +112,60 @@ The lock objects are recursive locks, which means that once acquired, they will # And released here. +Timeouts and non-blocking locks +------------------------------- +The :meth:`acquire ` method accepts a ``timeout`` parameter. If the lock cannot be +acquired within ``timeout`` seconds, a :class:`Timeout ` exception is raised: + +.. code-block:: python + + try: + with lock.acquire(timeout=10): + with open(file_path, "a") as f: + f.write("I have a bad feeling about this.") + except Timeout: + print("Another instance of this application currently holds the lock.") + +Using a ``timeout < 0`` makes the lock block until it can be acquired +while ``timeout == 0`` results in only one attempt to acquire the lock before raising a :class:`Timeout ` exception (-> non-blocking). + +You can also use the ``blocking`` parameter to attempt a non-blocking :meth:`acquire `. + +.. code-block:: python + + try: + with lock.acquire(blocking=False): + with open(file_path, "a") as f: + f.write("I have a bad feeling about this.") + except Timeout: + print("Another instance of this application currently holds the lock.") + + +The ``blocking`` option takes precedence over ``timeout``. +Meaning, if you set ``blocking=False`` while ``timeout > 0``, a :class:`Timeout ` exception is raised without waiting for the lock to release. + +You can pre-parametrize both of these options when constructing the lock for ease-of-use. + +.. code-block:: python + + from filelock import Timeout, FileLock + + lock_1 = FileLock("high_ground.txt.lock", blocking = False) + try: + with lock_1: + # do some work + pass + except Timeout: + print("Well, we tried once and couldn't acquire.") + + lock_2 = FileLock("high_ground.txt.lock", timeout = 10) + try: + with lock_2: + # do some other work + pass + except Timeout: + print("Ten seconds feel like forever sometimes.") + Logging ------- All log messages by this library are made using the ``DEBUG_ level``, under the ``filelock`` name. On how to control diff --git a/src/filelock/_api.py b/src/filelock/_api.py index 210b8c4..fd87972 100644 --- a/src/filelock/_api.py +++ b/src/filelock/_api.py @@ -63,6 +63,9 @@ class FileLockContext: #: The mode for the lock files mode: int + #: Whether the lock should be blocking or not + blocking: bool + #: The file descriptor for the *_lock_file* as it is returned by the os.open() function, not None when lock held lock_file_fd: int | None = None @@ -86,6 +89,7 @@ def __new__( # noqa: PLR0913 mode: int = 0o644, thread_local: bool = True, # noqa: ARG003, FBT001, FBT002 *, + blocking: bool = True, # noqa: ARG003 is_singleton: bool = False, **kwargs: dict[str, Any], # capture remaining kwargs for subclasses # noqa: ARG003 ) -> Self: @@ -115,6 +119,7 @@ def __init__( # noqa: PLR0913 mode: int = 0o644, thread_local: bool = True, # noqa: FBT001, FBT002 *, + blocking: bool = True, is_singleton: bool = False, ) -> None: """ @@ -127,6 +132,7 @@ def __init__( # noqa: PLR0913 :param mode: file permissions for the lockfile :param thread_local: Whether this object's internal context should be thread local or not. If this is set to \ ``False`` then the lock will be reentrant across threads. + :param blocking: whether the lock should be blocking or not :param is_singleton: If this is set to ``True`` then only one instance of this class will be created \ per lock file. This is useful if you want to use the lock object for reentrant locking without needing \ to pass the same object around. @@ -135,12 +141,13 @@ def __init__( # noqa: PLR0913 self._is_thread_local = thread_local self._is_singleton = is_singleton - # Create the context. Note that external code should not work with the context directly and should instead use + # Create the context. Note that external code should not work with the context directly and should instead use # properties of this class. kwargs: dict[str, Any] = { "lock_file": os.fspath(lock_file), "timeout": timeout, "mode": mode, + "blocking": blocking, } self._context: FileLockContext = (ThreadLocalFileContext if thread_local else FileLockContext)(**kwargs) @@ -177,6 +184,21 @@ def timeout(self, value: float | str) -> None: """ self._context.timeout = float(value) + @property + def blocking(self) -> bool: + """:return: whether the locking is blocking or not""" + return self._context.blocking + + @blocking.setter + def blocking(self, value: bool) -> None: + """ + Change the default blocking value. + + :param value: the new value as bool + + """ + self._context.blocking = value + @property def mode(self) -> int: """:return: the file permissions for the lockfile""" @@ -215,7 +237,7 @@ def acquire( poll_interval: float = 0.05, *, poll_intervall: float | None = None, - blocking: bool = True, + blocking: bool | None = None, ) -> AcquireReturnProxy: """ Try to acquire the file lock. @@ -252,6 +274,9 @@ def acquire( if timeout is None: timeout = self._context.timeout + if blocking is None: + blocking = self._context.blocking + if poll_intervall is not None: msg = "use poll_interval instead of poll_intervall" warnings.warn(msg, DeprecationWarning, stacklevel=2) diff --git a/tests/test_filelock.py b/tests/test_filelock.py index 9d3bd25..e23699f 100644 --- a/tests/test_filelock.py +++ b/tests/test_filelock.py @@ -303,11 +303,17 @@ def test_non_blocking(lock_type: type[BaseFileLock], tmp_path: Path) -> None: # raises Timeout error when the lock cannot be acquired lock_path = tmp_path / "a" lock_1, lock_2 = lock_type(str(lock_path)), lock_type(str(lock_path)) + lock_3 = lock_type(str(lock_path), blocking=False) + lock_4 = lock_type(str(lock_path), timeout=0) + lock_5 = lock_type(str(lock_path), blocking=False, timeout=-1) # acquire lock 1 lock_1.acquire() assert lock_1.is_locked assert not lock_2.is_locked + assert not lock_3.is_locked + assert not lock_4.is_locked + assert not lock_5.is_locked # try to acquire lock 2 with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): @@ -315,10 +321,50 @@ def test_non_blocking(lock_type: type[BaseFileLock], tmp_path: Path) -> None: assert not lock_2.is_locked assert lock_1.is_locked + # try to acquire pre-parametrized `blocking=False` lock 3 with `acquire` + with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): + lock_3.acquire() + assert not lock_3.is_locked + assert lock_1.is_locked + + # try to acquire pre-parametrized `blocking=False` lock 3 with context manager + with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."), lock_3: + pass + assert not lock_3.is_locked + assert lock_1.is_locked + + # try to acquire pre-parametrized `timeout=0` lock 4 with `acquire` + with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): + lock_4.acquire() + assert not lock_4.is_locked + assert lock_1.is_locked + + # try to acquire pre-parametrized `timeout=0` lock 4 with context manager + with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."), lock_4: + pass + assert not lock_4.is_locked + assert lock_1.is_locked + + # blocking precedence over timeout + # try to acquire pre-parametrized `timeout=-1,blocking=False` lock 5 with `acquire` + with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."): + lock_5.acquire() + assert not lock_5.is_locked + assert lock_1.is_locked + + # try to acquire pre-parametrized `timeout=-1,blocking=False` lock 5 with context manager + with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."), lock_5: + pass + assert not lock_5.is_locked + assert lock_1.is_locked + # release lock 1 lock_1.release() assert not lock_1.is_locked assert not lock_2.is_locked + assert not lock_3.is_locked + assert not lock_4.is_locked + assert not lock_5.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock])