Skip to content

Commit b4713c9

Browse files
Conditionally disable/enable thread-local lock behavior. (#232)
Co-authored-by: Bernát Gábor <[email protected]>
1 parent 9c44c11 commit b4713c9

File tree

8 files changed

+139
-54
lines changed

8 files changed

+139
-54
lines changed

docs/changelog.rst

+6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
Changelog
22
=========
3+
v3.12.0 (2023-04-18)
4+
--------------------
5+
- Make the thread local behaviour something the caller can enable/disable via a flag during the lock creation, it's on
6+
by default.
7+
- Better error handling on Windows.
8+
39
v3.11.0 (2023-04-06)
410
--------------------
511
- Make the lock thread local.

docs/index.rst

+23
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,29 @@ Asyncio support
143143
This library currently does not support asyncio. We'd recommend adding an asyncio variant though if someone can make a
144144
pull request for it, `see here <https://github.com/tox-dev/py-filelock/issues/99>`_.
145145

146+
FileLocks and threads
147+
---------------------
148+
149+
By default the :class:`FileLock <filelock.FileLock>` internally uses :class:`threading.local <threading.local>`
150+
to ensure that the lock is thread-local. If you have a use case where you'd like an instance of ``FileLock`` to be shared
151+
across threads, you can set the ``thread_local`` parameter to ``False`` when creating a lock. For example:
152+
153+
.. code-block:: python
154+
155+
lock = FileLock("test.lock", thread_local=False)
156+
# lock will be re-entrant across threads
157+
158+
# The same behavior would also work with other instances of BaseFileLock like SoftFileLock:
159+
soft_lock = SoftFileLock("soft_test.lock", thread_local=False)
160+
# soft_lock will be re-entrant across threads.
161+
162+
163+
Behavior where :class:`FileLock <filelock.FileLock>` is thread-local started in version 3.11.0. Previous versions,
164+
were not thread-local by default.
165+
166+
Note: If disabling thread-local, be sure to remember that locks are re-entrant: You will be able to
167+
:meth:`acquire <filelock.BaseFileLock.acquire>` the same lock multiple times across multiple threads.
168+
146169
Contributions and issues
147170
------------------------
148171

src/filelock/__init__.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,10 @@
3232
if warnings is not None:
3333
warnings.warn("only soft file lock is available", stacklevel=2)
3434

35-
#: Alias for the lock, which should be used for the current platform. On Windows, this is an alias for
36-
# :class:`WindowsFileLock`, on Unix for :class:`UnixFileLock` and otherwise for :class:`SoftFileLock`.
3735
if TYPE_CHECKING:
3836
FileLock = SoftFileLock
3937
else:
38+
#: Alias for the lock, which should be used for the current platform.
4039
FileLock = _FileLock
4140

4241

src/filelock/_api.py

+73-33
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import time
77
import warnings
88
from abc import ABC, abstractmethod
9+
from dataclasses import dataclass
910
from threading import local
1011
from types import TracebackType
1112
from typing import Any
@@ -36,14 +37,46 @@ def __exit__(
3637
self.lock.release()
3738

3839

39-
class BaseFileLock(ABC, contextlib.ContextDecorator, local):
40+
@dataclass
41+
class FileLockContext:
42+
"""
43+
A dataclass which holds the context for a ``BaseFileLock`` object.
44+
"""
45+
46+
# The context is held in a separate class to allow optional use of thread local storage via the
47+
# ThreadLocalFileContext class.
48+
49+
#: The path to the lock file.
50+
lock_file: str
51+
52+
#: The default timeout value.
53+
timeout: float
54+
55+
#: The mode for the lock files
56+
mode: int
57+
58+
#: The file descriptor for the *_lock_file* as it is returned by the os.open() function, not None when lock held
59+
lock_file_fd: int | None = None
60+
61+
#: The lock counter is used for implementing the nested locking mechanism.
62+
lock_counter: int = 0 # When the lock is acquired is increased and the lock is only released, when this value is 0
63+
64+
65+
class ThreadLocalFileContext(FileLockContext, local):
66+
"""
67+
A thread local version of the ``FileLockContext`` class.
68+
"""
69+
70+
71+
class BaseFileLock(ABC, contextlib.ContextDecorator):
4072
"""Abstract base class for a file lock object."""
4173

4274
def __init__(
4375
self,
4476
lock_file: str | os.PathLike[Any],
4577
timeout: float = -1,
4678
mode: int = 0o644,
79+
thread_local: bool = True,
4780
) -> None:
4881
"""
4982
Create a new lock object.
@@ -52,29 +85,29 @@ def __init__(
5285
:param timeout: default timeout when acquiring the lock, in seconds. It will be used as fallback value in
5386
the acquire method, if no timeout value (``None``) is given. If you want to disable the timeout, set it
5487
to a negative value. A timeout of 0 means, that there is exactly one attempt to acquire the file lock.
55-
: param mode: file permissions for the lockfile.
88+
:param mode: file permissions for the lockfile.
89+
:param thread_local: Whether this object's internal context should be thread local or not.
90+
If this is set to ``False`` then the lock will be reentrant across threads.
5691
"""
57-
# The path to the lock file.
58-
self._lock_file: str = os.fspath(lock_file)
59-
60-
# The file descriptor for the *_lock_file* as it is returned by the os.open() function.
61-
# This file lock is only NOT None, if the object currently holds the lock.
62-
self._lock_file_fd: int | None = None
63-
64-
# The default timeout value.
65-
self._timeout: float = timeout
92+
self._is_thread_local = thread_local
6693

67-
# The mode for the lock files
68-
self._mode: int = mode
94+
# Create the context. Note that external code should not work with the context directly and should instead use
95+
# properties of this class.
96+
kwargs: dict[str, Any] = {
97+
"lock_file": os.fspath(lock_file),
98+
"timeout": timeout,
99+
"mode": mode,
100+
}
101+
self._context: FileLockContext = (ThreadLocalFileContext if thread_local else FileLockContext)(**kwargs)
69102

70-
# The lock counter is used for implementing the nested locking mechanism. Whenever the lock is acquired, the
71-
# counter is increased and the lock is only released, when this value is 0 again.
72-
self._lock_counter: int = 0
103+
def is_thread_local(self) -> bool:
104+
""":return: a flag indicating if this lock is thread local or not"""
105+
return self._is_thread_local
73106

74107
@property
75108
def lock_file(self) -> str:
76109
""":return: path to the lock file"""
77-
return self._lock_file
110+
return self._context.lock_file
78111

79112
@property
80113
def timeout(self) -> float:
@@ -83,7 +116,7 @@ def timeout(self) -> float:
83116
84117
.. versionadded:: 2.0.0
85118
"""
86-
return self._timeout
119+
return self._context.timeout
87120

88121
@timeout.setter
89122
def timeout(self, value: float | str) -> None:
@@ -92,16 +125,16 @@ def timeout(self, value: float | str) -> None:
92125
93126
:param value: the new value, in seconds
94127
"""
95-
self._timeout = float(value)
128+
self._context.timeout = float(value)
96129

97130
@abstractmethod
98131
def _acquire(self) -> None:
99-
"""If the file lock could be acquired, self._lock_file_fd holds the file descriptor of the lock file."""
132+
"""If the file lock could be acquired, self._context.lock_file_fd holds the file descriptor of the lock file."""
100133
raise NotImplementedError
101134

102135
@abstractmethod
103136
def _release(self) -> None:
104-
"""Releases the lock and sets self._lock_file_fd to None."""
137+
"""Releases the lock and sets self._context.lock_file_fd to None."""
105138
raise NotImplementedError
106139

107140
@property
@@ -114,7 +147,14 @@ def is_locked(self) -> bool:
114147
115148
This was previously a method and is now a property.
116149
"""
117-
return self._lock_file_fd is not None
150+
return self._context.lock_file_fd is not None
151+
152+
@property
153+
def lock_counter(self) -> int:
154+
"""
155+
:return: The number of times this lock has been acquired (but not yet released).
156+
"""
157+
return self._context.lock_counter
118158

119159
def acquire(
120160
self,
@@ -132,7 +172,7 @@ def acquire(
132172
:param poll_interval: interval of trying to acquire the lock file
133173
:param poll_intervall: deprecated, kept for backwards compatibility, use ``poll_interval`` instead
134174
:param blocking: defaults to True. If False, function will return immediately if it cannot obtain a lock on the
135-
first attempt. Otherwise this method will block until the timeout expires or the lock is acquired.
175+
first attempt. Otherwise, this method will block until the timeout expires or the lock is acquired.
136176
:raises Timeout: if fails to acquire lock within the timeout period
137177
:return: a context object that will unlock the file when the context is exited
138178
@@ -157,18 +197,18 @@ def acquire(
157197
"""
158198
# Use the default timeout, if no timeout is provided.
159199
if timeout is None:
160-
timeout = self.timeout
200+
timeout = self._context.timeout
161201

162202
if poll_intervall is not None:
163203
msg = "use poll_interval instead of poll_intervall"
164204
warnings.warn(msg, DeprecationWarning, stacklevel=2)
165205
poll_interval = poll_intervall
166206

167207
# Increment the number right at the beginning. We can still undo it, if something fails.
168-
self._lock_counter += 1
208+
self._context.lock_counter += 1
169209

170210
lock_id = id(self)
171-
lock_filename = self._lock_file
211+
lock_filename = self.lock_file
172212
start_time = time.perf_counter()
173213
try:
174214
while True:
@@ -180,16 +220,16 @@ def acquire(
180220
break
181221
elif blocking is False:
182222
_LOGGER.debug("Failed to immediately acquire lock %s on %s", lock_id, lock_filename)
183-
raise Timeout(self._lock_file)
223+
raise Timeout(lock_filename)
184224
elif 0 <= timeout < time.perf_counter() - start_time:
185225
_LOGGER.debug("Timeout on acquiring lock %s on %s", lock_id, lock_filename)
186-
raise Timeout(self._lock_file)
226+
raise Timeout(lock_filename)
187227
else:
188228
msg = "Lock %s not acquired on %s, waiting %s seconds ..."
189229
_LOGGER.debug(msg, lock_id, lock_filename, poll_interval)
190230
time.sleep(poll_interval)
191231
except BaseException: # Something did go wrong, so decrement the counter.
192-
self._lock_counter = max(0, self._lock_counter - 1)
232+
self._context.lock_counter = max(0, self._context.lock_counter - 1)
193233
raise
194234
return AcquireReturnProxy(lock=self)
195235

@@ -201,14 +241,14 @@ def release(self, force: bool = False) -> None:
201241
:param force: If true, the lock counter is ignored and the lock is released in every case/
202242
"""
203243
if self.is_locked:
204-
self._lock_counter -= 1
244+
self._context.lock_counter -= 1
205245

206-
if self._lock_counter == 0 or force:
207-
lock_id, lock_filename = id(self), self._lock_file
246+
if self._context.lock_counter == 0 or force:
247+
lock_id, lock_filename = id(self), self.lock_file
208248

209249
_LOGGER.debug("Attempting to release lock %s on %s", lock_id, lock_filename)
210250
self._release()
211-
self._lock_counter = 0
251+
self._context.lock_counter = 0
212252
_LOGGER.debug("Lock %s released on %s", lock_id, lock_filename)
213253

214254
def __enter__(self) -> BaseFileLock:

src/filelock/_soft.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class SoftFileLock(BaseFileLock):
1212
"""Simply watches the existence of the lock file."""
1313

1414
def _acquire(self) -> None:
15-
raise_on_not_writable_file(self._lock_file)
15+
raise_on_not_writable_file(self.lock_file)
1616
# first check for exists and read-only mode as the open will mask this case as EEXIST
1717
flags = (
1818
os.O_WRONLY # open for writing only
@@ -21,21 +21,21 @@ def _acquire(self) -> None:
2121
| os.O_TRUNC # truncate the file to zero byte
2222
)
2323
try:
24-
file_handler = os.open(self._lock_file, flags, self._mode)
24+
file_handler = os.open(self.lock_file, flags, self._context.mode)
2525
except OSError as exception: # re-raise unless expected exception
2626
if not (
2727
exception.errno == EEXIST # lock already exist
2828
or (exception.errno == EACCES and sys.platform == "win32") # has no access to this lock
2929
): # pragma: win32 no cover
3030
raise
3131
else:
32-
self._lock_file_fd = file_handler
32+
self._context.lock_file_fd = file_handler
3333

3434
def _release(self) -> None:
35-
os.close(self._lock_file_fd) # type: ignore # the lock file is definitely not None
36-
self._lock_file_fd = None
35+
os.close(self._context.lock_file_fd) # type: ignore # the lock file is definitely not None
36+
self._context.lock_file_fd = None
3737
try:
38-
os.remove(self._lock_file)
38+
os.remove(self.lock_file)
3939
except OSError: # the file is already deleted and that's what we want
4040
pass
4141

src/filelock/_unix.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ class UnixFileLock(BaseFileLock):
3333

3434
def _acquire(self) -> None:
3535
open_flags = os.O_RDWR | os.O_CREAT | os.O_TRUNC
36-
fd = os.open(self._lock_file, open_flags, self._mode)
36+
fd = os.open(self.lock_file, open_flags, self._context.mode)
3737
try:
38-
os.fchmod(fd, self._mode)
38+
os.fchmod(fd, self._context.mode)
3939
except PermissionError:
4040
pass # This locked is not owned by this UID
4141
try:
@@ -45,14 +45,14 @@ def _acquire(self) -> None:
4545
if exception.errno == ENOSYS: # NotImplemented error
4646
raise NotImplementedError("FileSystem does not appear to support flock; user SoftFileLock instead")
4747
else:
48-
self._lock_file_fd = fd
48+
self._context.lock_file_fd = fd
4949

5050
def _release(self) -> None:
5151
# Do not remove the lockfile:
5252
# https://github.com/tox-dev/py-filelock/issues/31
5353
# https://stackoverflow.com/questions/17708885/flock-removing-locked-file-without-race-condition
54-
fd = cast(int, self._lock_file_fd)
55-
self._lock_file_fd = None
54+
fd = cast(int, self._context.lock_file_fd)
55+
self._context.lock_file_fd = None
5656
fcntl.flock(fd, fcntl.LOCK_UN)
5757
os.close(fd)
5858

src/filelock/_windows.py

+8-8
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,17 @@
1212
import msvcrt
1313

1414
class WindowsFileLock(BaseFileLock):
15-
"""Uses the :func:`msvcrt.locking` function to hard lock the lock file on windows systems."""
15+
"""Uses the :func:`msvcrt.locking` function to hard lock the lock file on Windows systems."""
1616

1717
def _acquire(self) -> None:
18-
raise_on_not_writable_file(self._lock_file)
18+
raise_on_not_writable_file(self.lock_file)
1919
flags = (
2020
os.O_RDWR # open for read and write
2121
| os.O_CREAT # create file if not exists
2222
| os.O_TRUNC # truncate file if not empty
2323
)
2424
try:
25-
fd = os.open(self._lock_file, flags, self._mode)
25+
fd = os.open(self.lock_file, flags, self._context.mode)
2626
except OSError as exception:
2727
if exception.errno != EACCES: # has no access to this lock
2828
raise
@@ -34,24 +34,24 @@ def _acquire(self) -> None:
3434
if exception.errno != EACCES: # file is already locked
3535
raise
3636
else:
37-
self._lock_file_fd = fd
37+
self._context.lock_file_fd = fd
3838

3939
def _release(self) -> None:
40-
fd = cast(int, self._lock_file_fd)
41-
self._lock_file_fd = None
40+
fd = cast(int, self._context.lock_file_fd)
41+
self._context.lock_file_fd = None
4242
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
4343
os.close(fd)
4444

4545
try:
46-
os.remove(self._lock_file)
46+
os.remove(self.lock_file)
4747
# Probably another instance of the application hat acquired the file lock.
4848
except OSError:
4949
pass
5050

5151
else: # pragma: win32 no cover
5252

5353
class WindowsFileLock(BaseFileLock):
54-
"""Uses the :func:`msvcrt.locking` function to hard lock the lock file on windows systems."""
54+
"""Uses the :func:`msvcrt.locking` function to hard lock the lock file on Windows systems."""
5555

5656
def _acquire(self) -> None:
5757
raise NotImplementedError

0 commit comments

Comments
 (0)