Skip to content

Commit 8556141

Browse files
authored
feat: blocking parameter on lock constructor with tests and docs (#325)
* 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 .
1 parent 26ccad3 commit 8556141

File tree

3 files changed

+127
-14
lines changed

3 files changed

+127
-14
lines changed

docs/index.rst

+54-12
Original file line numberDiff line numberDiff line change
@@ -89,18 +89,6 @@ already been done by other processes. For example, each process above first chec
8989
it is already created, we should not destroy the work of other processes. This is typically the case when we want
9090
just one process to write content into a file, and let every process to read the content.
9191

92-
The :meth:`acquire <filelock.BaseFileLock.acquire>` method accepts also a ``timeout`` parameter. If the lock cannot be
93-
acquired within ``timeout`` seconds, a :class:`Timeout <filelock.Timeout>` exception is raised:
94-
95-
.. code-block:: python
96-
97-
try:
98-
with lock.acquire(timeout=10):
99-
with open(file_path, "a") as f:
100-
f.write("I have a bad feeling about this.")
101-
except Timeout:
102-
print("Another instance of this application currently holds the lock.")
103-
10492
The lock objects are recursive locks, which means that once acquired, they will not block on successive lock requests:
10593

10694
.. code-block:: python
@@ -124,6 +112,60 @@ The lock objects are recursive locks, which means that once acquired, they will
124112
# And released here.
125113
126114
115+
Timeouts and non-blocking locks
116+
-------------------------------
117+
The :meth:`acquire <filelock.BaseFileLock.acquire>` method accepts a ``timeout`` parameter. If the lock cannot be
118+
acquired within ``timeout`` seconds, a :class:`Timeout <filelock.Timeout>` exception is raised:
119+
120+
.. code-block:: python
121+
122+
try:
123+
with lock.acquire(timeout=10):
124+
with open(file_path, "a") as f:
125+
f.write("I have a bad feeling about this.")
126+
except Timeout:
127+
print("Another instance of this application currently holds the lock.")
128+
129+
Using a ``timeout < 0`` makes the lock block until it can be acquired
130+
while ``timeout == 0`` results in only one attempt to acquire the lock before raising a :class:`Timeout <filelock.Timeout>` exception (-> non-blocking).
131+
132+
You can also use the ``blocking`` parameter to attempt a non-blocking :meth:`acquire <filelock.BaseFileLock.acquire>`.
133+
134+
.. code-block:: python
135+
136+
try:
137+
with lock.acquire(blocking=False):
138+
with open(file_path, "a") as f:
139+
f.write("I have a bad feeling about this.")
140+
except Timeout:
141+
print("Another instance of this application currently holds the lock.")
142+
143+
144+
The ``blocking`` option takes precedence over ``timeout``.
145+
Meaning, if you set ``blocking=False`` while ``timeout > 0``, a :class:`Timeout <filelock.Timeout>` exception is raised without waiting for the lock to release.
146+
147+
You can pre-parametrize both of these options when constructing the lock for ease-of-use.
148+
149+
.. code-block:: python
150+
151+
from filelock import Timeout, FileLock
152+
153+
lock_1 = FileLock("high_ground.txt.lock", blocking = False)
154+
try:
155+
with lock_1:
156+
# do some work
157+
pass
158+
except Timeout:
159+
print("Well, we tried once and couldn't acquire.")
160+
161+
lock_2 = FileLock("high_ground.txt.lock", timeout = 10)
162+
try:
163+
with lock_2:
164+
# do some other work
165+
pass
166+
except Timeout:
167+
print("Ten seconds feel like forever sometimes.")
168+
127169
Logging
128170
-------
129171
All log messages by this library are made using the ``DEBUG_ level``, under the ``filelock`` name. On how to control

src/filelock/_api.py

+27-2
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ class FileLockContext:
6363
#: The mode for the lock files
6464
mode: int
6565

66+
#: Whether the lock should be blocking or not
67+
blocking: bool
68+
6669
#: The file descriptor for the *_lock_file* as it is returned by the os.open() function, not None when lock held
6770
lock_file_fd: int | None = None
6871

@@ -86,6 +89,7 @@ def __new__( # noqa: PLR0913
8689
mode: int = 0o644,
8790
thread_local: bool = True, # noqa: ARG003, FBT001, FBT002
8891
*,
92+
blocking: bool = True, # noqa: ARG003
8993
is_singleton: bool = False,
9094
**kwargs: dict[str, Any], # capture remaining kwargs for subclasses # noqa: ARG003
9195
) -> Self:
@@ -115,6 +119,7 @@ def __init__( # noqa: PLR0913
115119
mode: int = 0o644,
116120
thread_local: bool = True, # noqa: FBT001, FBT002
117121
*,
122+
blocking: bool = True,
118123
is_singleton: bool = False,
119124
) -> None:
120125
"""
@@ -127,6 +132,7 @@ def __init__( # noqa: PLR0913
127132
:param mode: file permissions for the lockfile
128133
:param thread_local: Whether this object's internal context should be thread local or not. If this is set to \
129134
``False`` then the lock will be reentrant across threads.
135+
:param blocking: whether the lock should be blocking or not
130136
:param is_singleton: If this is set to ``True`` then only one instance of this class will be created \
131137
per lock file. This is useful if you want to use the lock object for reentrant locking without needing \
132138
to pass the same object around.
@@ -135,12 +141,13 @@ def __init__( # noqa: PLR0913
135141
self._is_thread_local = thread_local
136142
self._is_singleton = is_singleton
137143

138-
# Create the context. Note that external code should not work with the context directly and should instead use
144+
# Create the context. Note that external code should not work with the context directly and should instead use
139145
# properties of this class.
140146
kwargs: dict[str, Any] = {
141147
"lock_file": os.fspath(lock_file),
142148
"timeout": timeout,
143149
"mode": mode,
150+
"blocking": blocking,
144151
}
145152
self._context: FileLockContext = (ThreadLocalFileContext if thread_local else FileLockContext)(**kwargs)
146153

@@ -177,6 +184,21 @@ def timeout(self, value: float | str) -> None:
177184
"""
178185
self._context.timeout = float(value)
179186

187+
@property
188+
def blocking(self) -> bool:
189+
""":return: whether the locking is blocking or not"""
190+
return self._context.blocking
191+
192+
@blocking.setter
193+
def blocking(self, value: bool) -> None:
194+
"""
195+
Change the default blocking value.
196+
197+
:param value: the new value as bool
198+
199+
"""
200+
self._context.blocking = value
201+
180202
@property
181203
def mode(self) -> int:
182204
""":return: the file permissions for the lockfile"""
@@ -215,7 +237,7 @@ def acquire(
215237
poll_interval: float = 0.05,
216238
*,
217239
poll_intervall: float | None = None,
218-
blocking: bool = True,
240+
blocking: bool | None = None,
219241
) -> AcquireReturnProxy:
220242
"""
221243
Try to acquire the file lock.
@@ -252,6 +274,9 @@ def acquire(
252274
if timeout is None:
253275
timeout = self._context.timeout
254276

277+
if blocking is None:
278+
blocking = self._context.blocking
279+
255280
if poll_intervall is not None:
256281
msg = "use poll_interval instead of poll_intervall"
257282
warnings.warn(msg, DeprecationWarning, stacklevel=2)

tests/test_filelock.py

+46
Original file line numberDiff line numberDiff line change
@@ -303,22 +303,68 @@ def test_non_blocking(lock_type: type[BaseFileLock], tmp_path: Path) -> None:
303303
# raises Timeout error when the lock cannot be acquired
304304
lock_path = tmp_path / "a"
305305
lock_1, lock_2 = lock_type(str(lock_path)), lock_type(str(lock_path))
306+
lock_3 = lock_type(str(lock_path), blocking=False)
307+
lock_4 = lock_type(str(lock_path), timeout=0)
308+
lock_5 = lock_type(str(lock_path), blocking=False, timeout=-1)
306309

307310
# acquire lock 1
308311
lock_1.acquire()
309312
assert lock_1.is_locked
310313
assert not lock_2.is_locked
314+
assert not lock_3.is_locked
315+
assert not lock_4.is_locked
316+
assert not lock_5.is_locked
311317

312318
# try to acquire lock 2
313319
with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."):
314320
lock_2.acquire(blocking=False)
315321
assert not lock_2.is_locked
316322
assert lock_1.is_locked
317323

324+
# try to acquire pre-parametrized `blocking=False` lock 3 with `acquire`
325+
with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."):
326+
lock_3.acquire()
327+
assert not lock_3.is_locked
328+
assert lock_1.is_locked
329+
330+
# try to acquire pre-parametrized `blocking=False` lock 3 with context manager
331+
with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."), lock_3:
332+
pass
333+
assert not lock_3.is_locked
334+
assert lock_1.is_locked
335+
336+
# try to acquire pre-parametrized `timeout=0` lock 4 with `acquire`
337+
with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."):
338+
lock_4.acquire()
339+
assert not lock_4.is_locked
340+
assert lock_1.is_locked
341+
342+
# try to acquire pre-parametrized `timeout=0` lock 4 with context manager
343+
with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."), lock_4:
344+
pass
345+
assert not lock_4.is_locked
346+
assert lock_1.is_locked
347+
348+
# blocking precedence over timeout
349+
# try to acquire pre-parametrized `timeout=-1,blocking=False` lock 5 with `acquire`
350+
with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."):
351+
lock_5.acquire()
352+
assert not lock_5.is_locked
353+
assert lock_1.is_locked
354+
355+
# try to acquire pre-parametrized `timeout=-1,blocking=False` lock 5 with context manager
356+
with pytest.raises(Timeout, match="The file lock '.*' could not be acquired."), lock_5:
357+
pass
358+
assert not lock_5.is_locked
359+
assert lock_1.is_locked
360+
318361
# release lock 1
319362
lock_1.release()
320363
assert not lock_1.is_locked
321364
assert not lock_2.is_locked
365+
assert not lock_3.is_locked
366+
assert not lock_4.is_locked
367+
assert not lock_5.is_locked
322368

323369

324370
@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock])

0 commit comments

Comments
 (0)