diff --git a/CHANGES.md b/CHANGES.md index c7d6a7b7..837132f3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,7 @@ ### 3.32.0 (2024-xx-xx xx:xx:00 UTC) * 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 Requests library 2.31.0 (8812812) to 2.32.3 (0e322af) * Update urllib3 2.0.7 (56f01e0) to 2.2.1 (54d6edf) diff --git a/lib/filelock/__init__.py b/lib/filelock/__init__.py index 0b8c1d2f..006299d2 100644 --- a/lib/filelock/__init__.py +++ b/lib/filelock/__init__.py @@ -5,6 +5,7 @@ A platform independent file lock that supports the with-statement. :no-value: """ + from __future__ import annotations import sys @@ -32,7 +33,7 @@ else: # pragma: win32 no cover # noqa: PLR5501 if warnings is not None: warnings.warn("only soft file lock is available", stacklevel=2) -if TYPE_CHECKING: # noqa: SIM108 +if TYPE_CHECKING: FileLock = SoftFileLock else: #: Alias for the lock, which should be used for the current platform. @@ -40,12 +41,12 @@ else: __all__ = [ - "__version__", + "AcquireReturnProxy", + "BaseFileLock", "FileLock", "SoftFileLock", "Timeout", "UnixFileLock", "WindowsFileLock", - "BaseFileLock", - "AcquireReturnProxy", + "__version__", ] diff --git a/lib/filelock/_api.py b/lib/filelock/_api.py index 8a40ccd0..fd87972c 100644 --- a/lib/filelock/_api.py +++ b/lib/filelock/_api.py @@ -9,6 +9,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from threading import local from typing import TYPE_CHECKING, Any +from weakref import WeakValueDictionary 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 # again in the *__enter__* method of the BaseFileLock, but not released again automatically. issue #37 (memory leak) 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: self.lock = lock @@ -62,6 +63,9 @@ class FileLockContext: #: The mode for the lock files 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 lock_file_fd: int | None = None @@ -76,32 +80,74 @@ class ThreadLocalFileContext(FileLockContext, local): class BaseFileLock(ABC, contextlib.ContextDecorator): """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, lock_file: str | os.PathLike[str], timeout: float = -1, mode: int = 0o644, thread_local: bool = True, # noqa: FBT001, FBT002 + *, + blocking: bool = True, + is_singleton: bool = False, ) -> None: """ Create a new lock object. :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 - 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. - :param mode: file permissions for the lockfile. - :param thread_local: Whether this object's internal context should be thread local or not. - If this is set to ``False`` then the lock will be reentrant across threads. + :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 \ + 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 thread_local: Whether this object's internal context should be thread local or not. If this is set to \ + ``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_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. kwargs: dict[str, Any] = { "lock_file": os.fspath(lock_file), "timeout": timeout, "mode": mode, + "blocking": blocking, } 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 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 def lock_file(self) -> str: """:return: path to the lock file""" @@ -129,9 +180,30 @@ class BaseFileLock(ABC, contextlib.ContextDecorator): Change the default timeout value. :param value: the new value, in seconds + """ 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 def _acquire(self) -> None: """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_intervall: float | None = None, - blocking: bool = True, + blocking: bool | None = None, ) -> AcquireReturnProxy: """ Try to acquire the file lock. @@ -202,6 +274,9 @@ class BaseFileLock(ABC, contextlib.ContextDecorator): if timeout is None: timeout = self._context.timeout + if blocking is None: + blocking = self._context.blocking + if poll_intervall is not None: msg = "use poll_interval instead of poll_intervall" 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 """ - Releases the file lock. Please note, that the lock is only completely released, if the lock counter is 0. Also - note, that the lock file itself is not automatically deleted. + Releases the file lock. Please note, that the lock is only completely released, if the lock counter is 0. + 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/ + """ if self.is_locked: self._context.lock_counter -= 1 @@ -258,6 +334,7 @@ class BaseFileLock(ABC, contextlib.ContextDecorator): Acquire the lock. :return: the lock object + """ self.acquire() return self @@ -274,6 +351,7 @@ class BaseFileLock(ABC, contextlib.ContextDecorator): :param exc_type: the exception type if raised :param exc_value: the exception value if raised :param traceback: the exception traceback if raised + """ self.release() @@ -283,6 +361,6 @@ class BaseFileLock(ABC, contextlib.ContextDecorator): __all__ = [ - "BaseFileLock", "AcquireReturnProxy", + "BaseFileLock", ] diff --git a/lib/filelock/_unix.py b/lib/filelock/_unix.py index 93ce3be5..4ae1fbe9 100644 --- a/lib/filelock/_unix.py +++ b/lib/filelock/_unix.py @@ -4,6 +4,7 @@ import os import sys from contextlib import suppress from errno import ENOSYS +from pathlib import Path from typing import cast from ._api import BaseFileLock @@ -35,7 +36,9 @@ else: # pragma: win32 no cover def _acquire(self) -> None: 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) with suppress(PermissionError): # This locked is not owned by this UID os.fchmod(fd, self._context.mode) @@ -44,7 +47,7 @@ else: # pragma: win32 no cover except OSError as exception: os.close(fd) 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 else: self._context.lock_file_fd = fd @@ -60,6 +63,6 @@ else: # pragma: win32 no cover __all__ = [ - "has_fcntl", "UnixFileLock", + "has_fcntl", ] diff --git a/lib/filelock/_util.py b/lib/filelock/_util.py index 543c1394..c671e853 100644 --- a/lib/filelock/_util.py +++ b/lib/filelock/_util.py @@ -10,10 +10,13 @@ from pathlib import Path def raise_on_not_writable_file(filename: str) -> None: """ 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 :raises OSError: as if the file was opened for writing. + """ try: # use stat to do exists + can write to check without race condition 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: """ - 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. + """ Path(filename).parent.mkdir(parents=True, exist_ok=True) __all__ = [ - "raise_on_not_writable_file", "ensure_directory_exists", + "raise_on_not_writable_file", ] diff --git a/lib/filelock/version.py b/lib/filelock/version.py index e6e96d53..0afa0cef 100644 --- a/lib/filelock/version.py +++ b/lib/filelock/version.py @@ -1,4 +1,4 @@ # file generated by setuptools_scm # don't change, don't track in version control -__version__ = version = '3.12.4' -__version_tuple__ = version_tuple = (3, 12, 4) +__version__ = version = '3.14.0' +__version_tuple__ = version_tuple = (3, 14, 0)