Skip to content

Commit

Permalink
feat: blocking parameter on lock constructor with tests and docs (#325
Browse files Browse the repository at this point in the history
)

* feat: blocking param on lock constructor with tests and docs

* docs: replace a todo with actual doc

* better docs and additional test on blocking precedence

* fix: docs were broken

* docs: add a period .
  • Loading branch information
iamkhav authored Apr 27, 2024
1 parent 26ccad3 commit 8556141
Show file tree
Hide file tree
Showing 3 changed files with 127 additions and 14 deletions.
66 changes: 54 additions & 12 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <filelock.BaseFileLock.acquire>` method accepts also a ``timeout`` parameter. If the lock cannot be
acquired within ``timeout`` seconds, a :class:`Timeout <filelock.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
Expand All @@ -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 <filelock.BaseFileLock.acquire>` method accepts a ``timeout`` parameter. If the lock cannot be
acquired within ``timeout`` seconds, a :class:`Timeout <filelock.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 <filelock.Timeout>` exception (-> non-blocking).

You can also use the ``blocking`` parameter to attempt a non-blocking :meth:`acquire <filelock.BaseFileLock.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 <filelock.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
Expand Down
29 changes: 27 additions & 2 deletions src/filelock/_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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:
"""
Expand All @@ -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.
Expand All @@ -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)

Expand Down Expand Up @@ -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"""
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
46 changes: 46 additions & 0 deletions tests/test_filelock.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,22 +303,68 @@ 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."):
lock_2.acquire(blocking=False)
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])
Expand Down

0 comments on commit 8556141

Please sign in to comment.