Skip to content

Commit 87453f3

Browse files
asyncio support (#332)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 0ee2e3c commit 87453f3

File tree

5 files changed

+515
-1
lines changed

5 files changed

+515
-1
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ optional-dependencies.testing = [
5050
"coverage>=7.3.2",
5151
"diff-cover>=8.0.1",
5252
"pytest>=7.4.3",
53+
"pytest-asyncio>=0.21",
5354
"pytest-cov>=4.1",
5455
"pytest-mock>=3.12",
5556
"pytest-timeout>=2.2",

src/filelock/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@
1717
from ._soft import SoftFileLock
1818
from ._unix import UnixFileLock, has_fcntl
1919
from ._windows import WindowsFileLock
20+
from .asyncio import (
21+
AsyncAcquireReturnProxy,
22+
AsyncSoftFileLock,
23+
AsyncUnixFileLock,
24+
AsyncWindowsFileLock,
25+
BaseAsyncFileLock,
26+
)
2027
from .version import version
2128

2229
#: version of the project as a string
@@ -25,23 +32,34 @@
2532

2633
if sys.platform == "win32": # pragma: win32 cover
2734
_FileLock: type[BaseFileLock] = WindowsFileLock
35+
_AsyncFileLock: type[BaseAsyncFileLock] = AsyncWindowsFileLock
2836
else: # pragma: win32 no cover # noqa: PLR5501
2937
if has_fcntl:
3038
_FileLock: type[BaseFileLock] = UnixFileLock
39+
_AsyncFileLock: type[BaseAsyncFileLock] = AsyncUnixFileLock
3140
else:
3241
_FileLock = SoftFileLock
42+
_AsyncFileLock = AsyncSoftFileLock
3343
if warnings is not None:
3444
warnings.warn("only soft file lock is available", stacklevel=2)
3545

3646
if TYPE_CHECKING:
3747
FileLock = SoftFileLock
48+
AsyncFileLock = AsyncSoftFileLock
3849
else:
3950
#: Alias for the lock, which should be used for the current platform.
4051
FileLock = _FileLock
52+
AsyncFileLock = _AsyncFileLock
4153

4254

4355
__all__ = [
4456
"AcquireReturnProxy",
57+
"AsyncAcquireReturnProxy",
58+
"AsyncFileLock",
59+
"AsyncSoftFileLock",
60+
"AsyncUnixFileLock",
61+
"AsyncWindowsFileLock",
62+
"BaseAsyncFileLock",
4563
"BaseFileLock",
4664
"FileLock",
4765
"SoftFileLock",

src/filelock/_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ def __new__( # noqa: PLR0913
9191
*,
9292
blocking: bool = True, # noqa: ARG003
9393
is_singleton: bool = False,
94-
**kwargs: dict[str, Any], # capture remaining kwargs for subclasses # noqa: ARG003
94+
**kwargs: Any, # capture remaining kwargs for subclasses # noqa: ARG003, ANN401
9595
) -> Self:
9696
"""Create a new lock object or if specified return the singleton instance for the lock file."""
9797
if not is_singleton:

src/filelock/asyncio.py

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
"""An asyncio-based implementation of the file lock."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import contextlib
7+
import logging
8+
import os
9+
import time
10+
from dataclasses import dataclass
11+
from threading import local
12+
from typing import TYPE_CHECKING, Any, Callable, NoReturn
13+
14+
from ._api import BaseFileLock, FileLockContext
15+
from ._error import Timeout
16+
from ._soft import SoftFileLock
17+
from ._unix import UnixFileLock
18+
from ._windows import WindowsFileLock
19+
20+
if TYPE_CHECKING:
21+
import sys
22+
from concurrent import futures
23+
from types import TracebackType
24+
25+
if sys.version_info >= (3, 11): # pragma: no cover (py311+)
26+
from typing import Self
27+
else: # pragma: no cover (<py311)
28+
from typing_extensions import Self
29+
30+
31+
_LOGGER = logging.getLogger("filelock")
32+
33+
34+
@dataclass
35+
class AsyncFileLockContext(FileLockContext):
36+
"""A dataclass which holds the context for a ``BaseAsyncFileLock`` object."""
37+
38+
#: Whether run in executor
39+
run_in_executor: bool = True
40+
41+
#: The executor
42+
executor: futures.Executor | None = None
43+
44+
#: The loop
45+
loop: asyncio.AbstractEventLoop | None = None
46+
47+
48+
class AsyncThreadLocalFileContext(AsyncFileLockContext, local):
49+
"""A thread local version of the ``FileLockContext`` class."""
50+
51+
52+
class AsyncAcquireReturnProxy:
53+
"""A context-aware object that will release the lock file when exiting."""
54+
55+
def __init__(self, lock: BaseAsyncFileLock) -> None: # noqa: D107
56+
self.lock = lock
57+
58+
async def __aenter__(self) -> BaseAsyncFileLock: # noqa: D105
59+
return self.lock
60+
61+
async def __aexit__( # noqa: D105
62+
self,
63+
exc_type: type[BaseException] | None,
64+
exc_value: BaseException | None,
65+
traceback: TracebackType | None,
66+
) -> None:
67+
await self.lock.release()
68+
69+
70+
class BaseAsyncFileLock(BaseFileLock):
71+
"""Base class for asynchronous file locks."""
72+
73+
def __init__( # noqa: PLR0913
74+
self,
75+
lock_file: str | os.PathLike[str],
76+
timeout: float = -1,
77+
mode: int = 0o644,
78+
thread_local: bool = False, # noqa: FBT001, FBT002
79+
*,
80+
blocking: bool = True,
81+
is_singleton: bool = False,
82+
loop: asyncio.AbstractEventLoop | None = None,
83+
run_in_executor: bool = True,
84+
executor: futures.Executor | None = None,
85+
) -> None:
86+
"""
87+
Create a new lock object.
88+
89+
:param lock_file: path to the file
90+
:param timeout: default timeout when acquiring the lock, in seconds. It will be used as fallback value in \
91+
the acquire method, if no timeout value (``None``) is given. If you want to disable the timeout, set it \
92+
to a negative value. A timeout of 0 means that there is exactly one attempt to acquire the file lock.
93+
:param mode: file permissions for the lockfile
94+
:param thread_local: Whether this object's internal context should be thread local or not. If this is set to \
95+
``False`` then the lock will be reentrant across threads.
96+
:param blocking: whether the lock should be blocking or not
97+
:param is_singleton: If this is set to ``True`` then only one instance of this class will be created \
98+
per lock file. This is useful if you want to use the lock object for reentrant locking without needing \
99+
to pass the same object around.
100+
:param loop: The event loop to use. If not specified, the running event loop will be used.
101+
:param run_in_executor: If this is set to ``True`` then the lock will be acquired in an executor.
102+
:param executor: The executor to use. If not specified, the default executor will be used.
103+
104+
"""
105+
self._is_thread_local = thread_local
106+
self._is_singleton = is_singleton
107+
if thread_local and run_in_executor:
108+
msg = "run_in_executor is not supported when thread_local is True"
109+
raise ValueError(msg)
110+
111+
# Create the context. Note that external code should not work with the context directly and should instead use
112+
# properties of this class.
113+
kwargs: dict[str, Any] = {
114+
"lock_file": os.fspath(lock_file),
115+
"timeout": timeout,
116+
"mode": mode,
117+
"blocking": blocking,
118+
"loop": loop,
119+
"run_in_executor": run_in_executor,
120+
"executor": executor,
121+
}
122+
self._context: AsyncFileLockContext = (AsyncThreadLocalFileContext if thread_local else AsyncFileLockContext)(
123+
**kwargs
124+
)
125+
126+
@property
127+
def run_in_executor(self) -> bool:
128+
"""::return: whether run in executor."""
129+
return self._context.run_in_executor
130+
131+
@property
132+
def executor(self) -> futures.Executor | None:
133+
"""::return: the executor."""
134+
return self._context.executor
135+
136+
@executor.setter
137+
def executor(self, value: futures.Executor | None) -> None: # pragma: no cover
138+
"""
139+
Change the executor.
140+
141+
:param value: the new executor or ``None``
142+
:type value: futures.Executor | None
143+
144+
"""
145+
self._context.executor = value
146+
147+
@property
148+
def loop(self) -> asyncio.AbstractEventLoop | None:
149+
"""::return: the event loop."""
150+
return self._context.loop
151+
152+
async def acquire( # type: ignore[override]
153+
self,
154+
timeout: float | None = None,
155+
poll_interval: float = 0.05,
156+
*,
157+
blocking: bool | None = None,
158+
) -> AsyncAcquireReturnProxy:
159+
"""
160+
Try to acquire the file lock.
161+
162+
:param timeout: maximum wait time for acquiring the lock, ``None`` means use the default
163+
:attr:`~BaseFileLock.timeout` is and if ``timeout < 0``, there is no timeout and
164+
this method will block until the lock could be acquired
165+
:param poll_interval: interval of trying to acquire the lock file
166+
:param blocking: defaults to True. If False, function will return immediately if it cannot obtain a lock on the
167+
first attempt. Otherwise, this method will block until the timeout expires or the lock is acquired.
168+
:raises Timeout: if fails to acquire lock within the timeout period
169+
:return: a context object that will unlock the file when the context is exited
170+
171+
.. code-block:: python
172+
173+
# You can use this method in the context manager (recommended)
174+
with lock.acquire():
175+
pass
176+
177+
# Or use an equivalent try-finally construct:
178+
lock.acquire()
179+
try:
180+
pass
181+
finally:
182+
lock.release()
183+
184+
"""
185+
# Use the default timeout, if no timeout is provided.
186+
if timeout is None:
187+
timeout = self._context.timeout
188+
189+
if blocking is None:
190+
blocking = self._context.blocking
191+
192+
# Increment the number right at the beginning. We can still undo it, if something fails.
193+
self._context.lock_counter += 1
194+
195+
lock_id = id(self)
196+
lock_filename = self.lock_file
197+
start_time = time.perf_counter()
198+
try:
199+
while True:
200+
if not self.is_locked:
201+
_LOGGER.debug("Attempting to acquire lock %s on %s", lock_id, lock_filename)
202+
await self._run_internal_method(self._acquire)
203+
if self.is_locked:
204+
_LOGGER.debug("Lock %s acquired on %s", lock_id, lock_filename)
205+
break
206+
if blocking is False:
207+
_LOGGER.debug("Failed to immediately acquire lock %s on %s", lock_id, lock_filename)
208+
raise Timeout(lock_filename) # noqa: TRY301
209+
if 0 <= timeout < time.perf_counter() - start_time:
210+
_LOGGER.debug("Timeout on acquiring lock %s on %s", lock_id, lock_filename)
211+
raise Timeout(lock_filename) # noqa: TRY301
212+
msg = "Lock %s not acquired on %s, waiting %s seconds ..."
213+
_LOGGER.debug(msg, lock_id, lock_filename, poll_interval)
214+
await asyncio.sleep(poll_interval)
215+
except BaseException: # Something did go wrong, so decrement the counter.
216+
self._context.lock_counter = max(0, self._context.lock_counter - 1)
217+
raise
218+
return AsyncAcquireReturnProxy(lock=self)
219+
220+
async def release(self, force: bool = False) -> None: # type: ignore[override] # noqa: FBT001, FBT002
221+
"""
222+
Releases the file lock. Please note, that the lock is only completely released, if the lock counter is 0.
223+
Also note, that the lock file itself is not automatically deleted.
224+
225+
:param force: If true, the lock counter is ignored and the lock is released in every case/
226+
227+
"""
228+
if self.is_locked:
229+
self._context.lock_counter -= 1
230+
231+
if self._context.lock_counter == 0 or force:
232+
lock_id, lock_filename = id(self), self.lock_file
233+
234+
_LOGGER.debug("Attempting to release lock %s on %s", lock_id, lock_filename)
235+
await self._run_internal_method(self._release)
236+
self._context.lock_counter = 0
237+
_LOGGER.debug("Lock %s released on %s", lock_id, lock_filename)
238+
239+
async def _run_internal_method(self, method: Callable[[], Any]) -> None:
240+
if asyncio.iscoroutinefunction(method):
241+
await method()
242+
elif self.run_in_executor:
243+
loop = self.loop or asyncio.get_running_loop()
244+
await loop.run_in_executor(self.executor, method)
245+
else:
246+
method()
247+
248+
def __enter__(self) -> NoReturn:
249+
"""
250+
Replace old __enter__ method to avoid using it.
251+
252+
NOTE: DO NOT USE `with` FOR ASYNCIO LOCKS, USE `async with` INSTEAD.
253+
254+
:return: none
255+
:rtype: NoReturn
256+
"""
257+
msg = "Do not use `with` for asyncio locks, use `async with` instead."
258+
raise NotImplementedError(msg)
259+
260+
async def __aenter__(self) -> Self:
261+
"""
262+
Acquire the lock.
263+
264+
:return: the lock object
265+
266+
"""
267+
await self.acquire()
268+
return self
269+
270+
async def __aexit__(
271+
self,
272+
exc_type: type[BaseException] | None,
273+
exc_value: BaseException | None,
274+
traceback: TracebackType | None,
275+
) -> None:
276+
"""
277+
Release the lock.
278+
279+
:param exc_type: the exception type if raised
280+
:param exc_value: the exception value if raised
281+
:param traceback: the exception traceback if raised
282+
283+
"""
284+
await self.release()
285+
286+
def __del__(self) -> None:
287+
"""Called when the lock object is deleted."""
288+
with contextlib.suppress(RuntimeError):
289+
loop = self.loop or asyncio.get_running_loop()
290+
if not loop.is_running(): # pragma: no cover
291+
loop.run_until_complete(self.release(force=True))
292+
else:
293+
loop.create_task(self.release(force=True))
294+
295+
296+
class AsyncSoftFileLock(SoftFileLock, BaseAsyncFileLock):
297+
"""Simply watches the existence of the lock file."""
298+
299+
300+
class AsyncUnixFileLock(UnixFileLock, BaseAsyncFileLock):
301+
"""Uses the :func:`fcntl.flock` to hard lock the lock file on unix systems."""
302+
303+
304+
class AsyncWindowsFileLock(WindowsFileLock, BaseAsyncFileLock):
305+
"""Uses the :func:`msvcrt.locking` to hard lock the lock file on windows systems."""
306+
307+
308+
__all__ = [
309+
"AsyncAcquireReturnProxy",
310+
"AsyncSoftFileLock",
311+
"AsyncUnixFileLock",
312+
"AsyncWindowsFileLock",
313+
"BaseAsyncFileLock",
314+
]

0 commit comments

Comments
 (0)