66import time
77import warnings
88from abc import ABC , abstractmethod
9+ from dataclasses import dataclass
910from threading import local
1011from types import TracebackType
1112from 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 :
0 commit comments