Merge branch 'feature/UpdateFilelock' into dev

This commit is contained in:
JackDandy 2024-06-05 09:40:37 +01:00
commit 6b72b58cba
6 changed files with 114 additions and 26 deletions

View file

@ -1,6 +1,7 @@
### 3.32.0 (2024-xx-xx xx:xx:00 UTC) ### 3.32.0 (2024-xx-xx xx:xx:00 UTC)
* Update CacheControl 0.13.1 (783a338) to 0.14.0 (e2be0c2) * Update CacheControl 0.13.1 (783a338) to 0.14.0 (e2be0c2)
* Update filelock 3.12.4 (c1163ae) to 3.14.0 (8556141)
* Update idna library 3.4 (cab054c) to 3.7 (1d365e1) * Update idna library 3.4 (cab054c) to 3.7 (1d365e1)
* Update Requests library 2.31.0 (8812812) to 2.32.3 (0e322af) * Update Requests library 2.31.0 (8812812) to 2.32.3 (0e322af)
* Update urllib3 2.0.7 (56f01e0) to 2.2.1 (54d6edf) * Update urllib3 2.0.7 (56f01e0) to 2.2.1 (54d6edf)

View file

@ -5,6 +5,7 @@ A platform independent file lock that supports the with-statement.
:no-value: :no-value:
""" """
from __future__ import annotations from __future__ import annotations
import sys import sys
@ -32,7 +33,7 @@ else: # pragma: win32 no cover # noqa: PLR5501
if warnings is not None: if warnings is not None:
warnings.warn("only soft file lock is available", stacklevel=2) warnings.warn("only soft file lock is available", stacklevel=2)
if TYPE_CHECKING: # noqa: SIM108 if TYPE_CHECKING:
FileLock = SoftFileLock FileLock = SoftFileLock
else: else:
#: Alias for the lock, which should be used for the current platform. #: Alias for the lock, which should be used for the current platform.
@ -40,12 +41,12 @@ else:
__all__ = [ __all__ = [
"__version__", "AcquireReturnProxy",
"BaseFileLock",
"FileLock", "FileLock",
"SoftFileLock", "SoftFileLock",
"Timeout", "Timeout",
"UnixFileLock", "UnixFileLock",
"WindowsFileLock", "WindowsFileLock",
"BaseFileLock", "__version__",
"AcquireReturnProxy",
] ]

View file

@ -9,6 +9,7 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from threading import local from threading import local
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from weakref import WeakValueDictionary
from ._error import Timeout from ._error import Timeout
@ -29,7 +30,7 @@ _LOGGER = logging.getLogger("filelock")
# is not called twice when entering the with statement. If we would simply return *self*, the lock would be acquired # is not called twice when entering the with statement. If we would simply return *self*, the lock would be acquired
# again in the *__enter__* method of the BaseFileLock, but not released again automatically. issue #37 (memory leak) # again in the *__enter__* method of the BaseFileLock, but not released again automatically. issue #37 (memory leak)
class AcquireReturnProxy: class AcquireReturnProxy:
"""A context aware object that will release the lock file when exiting.""" """A context-aware object that will release the lock file when exiting."""
def __init__(self, lock: BaseFileLock) -> None: def __init__(self, lock: BaseFileLock) -> None:
self.lock = lock self.lock = lock
@ -62,6 +63,9 @@ class FileLockContext:
#: The mode for the lock files #: The mode for the lock files
mode: int 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 #: 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 lock_file_fd: int | None = None
@ -76,25 +80,66 @@ class ThreadLocalFileContext(FileLockContext, local):
class BaseFileLock(ABC, contextlib.ContextDecorator): class BaseFileLock(ABC, contextlib.ContextDecorator):
"""Abstract base class for a file lock object.""" """Abstract base class for a file lock object."""
def __init__( _instances: WeakValueDictionary[str, BaseFileLock]
def __new__( # noqa: PLR0913
cls,
lock_file: str | os.PathLike[str],
timeout: float = -1,
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:
"""Create a new lock object or if specified return the singleton instance for the lock file."""
if not is_singleton:
return super().__new__(cls)
instance = cls._instances.get(str(lock_file))
if not instance:
instance = super().__new__(cls)
cls._instances[str(lock_file)] = instance
elif timeout != instance.timeout or mode != instance.mode:
msg = "Singleton lock instances cannot be initialized with differing arguments"
raise ValueError(msg)
return instance # type: ignore[return-value] # https://github.com/python/mypy/issues/15322
def __init_subclass__(cls, **kwargs: dict[str, Any]) -> None:
"""Setup unique state for lock subclasses."""
super().__init_subclass__(**kwargs)
cls._instances = WeakValueDictionary()
def __init__( # noqa: PLR0913
self, self,
lock_file: str | os.PathLike[str], lock_file: str | os.PathLike[str],
timeout: float = -1, timeout: float = -1,
mode: int = 0o644, mode: int = 0o644,
thread_local: bool = True, # noqa: FBT001, FBT002 thread_local: bool = True, # noqa: FBT001, FBT002
*,
blocking: bool = True,
is_singleton: bool = False,
) -> None: ) -> None:
""" """
Create a new lock object. Create a new lock object.
:param lock_file: path to the file :param lock_file: path to the file
:param timeout: default timeout when acquiring the lock, in seconds. It will be used as fallback value in :param timeout: default timeout when acquiring the lock, in seconds. It will be used as fallback value in \
the acquire method, if no timeout value (``None``) is given. If you want to disable the timeout, set it the acquire method, if no timeout value (``None``) is given. If you want to disable the timeout, set it \
to a negative value. A timeout of 0 means, that there is exactly one attempt to acquire the file lock. to a negative value. A timeout of 0 means that there is exactly one attempt to acquire the file lock.
:param mode: file permissions for the lockfile. :param mode: file permissions for the lockfile
:param thread_local: Whether this object's internal context should be thread local or not. :param thread_local: Whether this object's internal context should be thread local or not. If this is set to \
If this is set to ``False`` then the lock will be reentrant across threads. ``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.
""" """
self._is_thread_local = thread_local 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. # properties of this class.
@ -102,6 +147,7 @@ class BaseFileLock(ABC, contextlib.ContextDecorator):
"lock_file": os.fspath(lock_file), "lock_file": os.fspath(lock_file),
"timeout": timeout, "timeout": timeout,
"mode": mode, "mode": mode,
"blocking": blocking,
} }
self._context: FileLockContext = (ThreadLocalFileContext if thread_local else FileLockContext)(**kwargs) self._context: FileLockContext = (ThreadLocalFileContext if thread_local else FileLockContext)(**kwargs)
@ -109,6 +155,11 @@ class BaseFileLock(ABC, contextlib.ContextDecorator):
""":return: a flag indicating if this lock is thread local or not""" """:return: a flag indicating if this lock is thread local or not"""
return self._is_thread_local return self._is_thread_local
@property
def is_singleton(self) -> bool:
""":return: a flag indicating if this lock is singleton or not"""
return self._is_singleton
@property @property
def lock_file(self) -> str: def lock_file(self) -> str:
""":return: path to the lock file""" """:return: path to the lock file"""
@ -129,9 +180,30 @@ class BaseFileLock(ABC, contextlib.ContextDecorator):
Change the default timeout value. Change the default timeout value.
:param value: the new value, in seconds :param value: the new value, in seconds
""" """
self._context.timeout = float(value) 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"""
return self._context.mode
@abstractmethod @abstractmethod
def _acquire(self) -> None: def _acquire(self) -> None:
"""If the file lock could be acquired, self._context.lock_file_fd holds the file descriptor of the lock file.""" """If the file lock could be acquired, self._context.lock_file_fd holds the file descriptor of the lock file."""
@ -165,7 +237,7 @@ class BaseFileLock(ABC, contextlib.ContextDecorator):
poll_interval: float = 0.05, poll_interval: float = 0.05,
*, *,
poll_intervall: float | None = None, poll_intervall: float | None = None,
blocking: bool = True, blocking: bool | None = None,
) -> AcquireReturnProxy: ) -> AcquireReturnProxy:
""" """
Try to acquire the file lock. Try to acquire the file lock.
@ -202,6 +274,9 @@ class BaseFileLock(ABC, contextlib.ContextDecorator):
if timeout is None: if timeout is None:
timeout = self._context.timeout timeout = self._context.timeout
if blocking is None:
blocking = self._context.blocking
if poll_intervall is not None: if poll_intervall is not None:
msg = "use poll_interval instead of poll_intervall" msg = "use poll_interval instead of poll_intervall"
warnings.warn(msg, DeprecationWarning, stacklevel=2) warnings.warn(msg, DeprecationWarning, stacklevel=2)
@ -237,10 +312,11 @@ class BaseFileLock(ABC, contextlib.ContextDecorator):
def release(self, force: bool = False) -> None: # noqa: FBT001, FBT002 def release(self, force: bool = False) -> None: # noqa: FBT001, FBT002
""" """
Releases the file lock. Please note, that the lock is only completely released, if the lock counter is 0. Also Releases the file lock. Please note, that the lock is only completely released, if the lock counter is 0.
note, that the lock file itself is not automatically deleted. Also note, that the lock file itself is not automatically deleted.
:param force: If true, the lock counter is ignored and the lock is released in every case/ :param force: If true, the lock counter is ignored and the lock is released in every case/
""" """
if self.is_locked: if self.is_locked:
self._context.lock_counter -= 1 self._context.lock_counter -= 1
@ -258,6 +334,7 @@ class BaseFileLock(ABC, contextlib.ContextDecorator):
Acquire the lock. Acquire the lock.
:return: the lock object :return: the lock object
""" """
self.acquire() self.acquire()
return self return self
@ -274,6 +351,7 @@ class BaseFileLock(ABC, contextlib.ContextDecorator):
:param exc_type: the exception type if raised :param exc_type: the exception type if raised
:param exc_value: the exception value if raised :param exc_value: the exception value if raised
:param traceback: the exception traceback if raised :param traceback: the exception traceback if raised
""" """
self.release() self.release()
@ -283,6 +361,6 @@ class BaseFileLock(ABC, contextlib.ContextDecorator):
__all__ = [ __all__ = [
"BaseFileLock",
"AcquireReturnProxy", "AcquireReturnProxy",
"BaseFileLock",
] ]

View file

@ -4,6 +4,7 @@ import os
import sys import sys
from contextlib import suppress from contextlib import suppress
from errno import ENOSYS from errno import ENOSYS
from pathlib import Path
from typing import cast from typing import cast
from ._api import BaseFileLock from ._api import BaseFileLock
@ -35,7 +36,9 @@ else: # pragma: win32 no cover
def _acquire(self) -> None: def _acquire(self) -> None:
ensure_directory_exists(self.lock_file) ensure_directory_exists(self.lock_file)
open_flags = os.O_RDWR | os.O_CREAT | os.O_TRUNC open_flags = os.O_RDWR | os.O_TRUNC
if not Path(self.lock_file).exists():
open_flags |= os.O_CREAT
fd = os.open(self.lock_file, open_flags, self._context.mode) fd = os.open(self.lock_file, open_flags, self._context.mode)
with suppress(PermissionError): # This locked is not owned by this UID with suppress(PermissionError): # This locked is not owned by this UID
os.fchmod(fd, self._context.mode) os.fchmod(fd, self._context.mode)
@ -44,7 +47,7 @@ else: # pragma: win32 no cover
except OSError as exception: except OSError as exception:
os.close(fd) os.close(fd)
if exception.errno == ENOSYS: # NotImplemented error if exception.errno == ENOSYS: # NotImplemented error
msg = "FileSystem does not appear to support flock; user SoftFileLock instead" msg = "FileSystem does not appear to support flock; use SoftFileLock instead"
raise NotImplementedError(msg) from exception raise NotImplementedError(msg) from exception
else: else:
self._context.lock_file_fd = fd self._context.lock_file_fd = fd
@ -60,6 +63,6 @@ else: # pragma: win32 no cover
__all__ = [ __all__ = [
"has_fcntl",
"UnixFileLock", "UnixFileLock",
"has_fcntl",
] ]

View file

@ -10,10 +10,13 @@ from pathlib import Path
def raise_on_not_writable_file(filename: str) -> None: def raise_on_not_writable_file(filename: str) -> None:
""" """
Raise an exception if attempting to open the file for writing would fail. Raise an exception if attempting to open the file for writing would fail.
This is done so files that will never be writable can be separated from
files that are writable but currently locked This is done so files that will never be writable can be separated from files that are writable but currently
locked.
:param filename: file to check :param filename: file to check
:raises OSError: as if the file was opened for writing. :raises OSError: as if the file was opened for writing.
""" """
try: # use stat to do exists + can write to check without race condition try: # use stat to do exists + can write to check without race condition
file_stat = os.stat(filename) # noqa: PTH116 file_stat = os.stat(filename) # noqa: PTH116
@ -35,13 +38,15 @@ def raise_on_not_writable_file(filename: str) -> None:
def ensure_directory_exists(filename: Path | str) -> None: def ensure_directory_exists(filename: Path | str) -> None:
""" """
Ensure the directory containing the file exists (create it if necessary) Ensure the directory containing the file exists (create it if necessary).
:param filename: file. :param filename: file.
""" """
Path(filename).parent.mkdir(parents=True, exist_ok=True) Path(filename).parent.mkdir(parents=True, exist_ok=True)
__all__ = [ __all__ = [
"raise_on_not_writable_file",
"ensure_directory_exists", "ensure_directory_exists",
"raise_on_not_writable_file",
] ]

View file

@ -1,4 +1,4 @@
# file generated by setuptools_scm # file generated by setuptools_scm
# don't change, don't track in version control # don't change, don't track in version control
__version__ = version = '3.12.4' __version__ = version = '3.14.0'
__version_tuple__ = version_tuple = (3, 12, 4) __version_tuple__ = version_tuple = (3, 14, 0)