Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-filelock for openSUSE:Factory checked in at 2023-04-29 17:27:34 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-filelock (Old) and /work/SRC/openSUSE:Factory/.python-filelock.new.1533 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-filelock" Sat Apr 29 17:27:34 2023 rev:12 rq:1083340 version:3.12.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-filelock/python-filelock.changes 2023-04-22 22:01:56.693748952 +0200 +++ /work/SRC/openSUSE:Factory/.python-filelock.new.1533/python-filelock.changes 2023-04-29 17:27:35.646404889 +0200 @@ -1,0 +2,28 @@ +Tue Apr 25 23:29:13 UTC 2023 - John Vandenberg <jay...@gmail.com> + +- Update to v3.12.0 + * Make the thread local behaviour something the caller can + enable/disable via a flag during the lock creation. on by default. + * Better error handling on Windows. +- from v3.11.0 + * Make the lock thread local. +- from v3.10.7 + * Use fchmod instead of chmod to work around bug in PyPy via Anaconda. +- from v3.10.6 + * Enhance the robustness of the try/catch block in _soft.py. +- from v3.10.5 + * Add explicit error check as certain UNIX filesystems do not support + flock. +- from v3.10.4 + * Update os.open to preserve mode= for certain edge cases. +- from v3.10.3 + * Fix permission issue +- from v3.10.2 + * Bug fix for using filelock with threaded programs causing undesired + file permissions +- from v3.10.1 + * Handle pickle for :class:`filelock.Timeout` +- from v3.10.0 + * Add support for explicit file modes for lockfiles + +------------------------------------------------------------------- Old: ---- filelock-3.9.1.tar.gz New: ---- filelock-3.12.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-filelock.spec ++++++ --- /var/tmp/diff_new_pack.lAp0tF/_old 2023-04-29 17:27:36.250407417 +0200 +++ /var/tmp/diff_new_pack.lAp0tF/_new 2023-04-29 17:27:36.254407435 +0200 @@ -19,7 +19,7 @@ %{?sle15_python_module_pythons} Name: python-filelock -Version: 3.9.1 +Version: 3.12.0 Release: 0 Summary: Platform Independent File Lock in Python License: Unlicense @@ -29,6 +29,7 @@ BuildRequires: %{python_module hatchling} BuildRequires: %{python_module pip} BuildRequires: %{python_module pytest} +BuildRequires: %{python_module pytest-mock} BuildRequires: %{python_module wheel} BuildRequires: fdupes BuildRequires: python-rpm-macros @@ -51,7 +52,7 @@ %python_expand %fdupes %{buildroot}/%{$python_sitelib} %check -%pytest +%pytest -rs %files %{python_files} %doc README.md ++++++ filelock-3.9.1.tar.gz -> filelock-3.12.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/filelock-3.9.1/PKG-INFO new/filelock-3.12.0/PKG-INFO --- old/filelock-3.9.1/PKG-INFO 2020-02-02 01:00:00.000000000 +0100 +++ new/filelock-3.12.0/PKG-INFO 2020-02-02 01:00:00.000000000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: filelock -Version: 3.9.1 +Version: 3.12.0 Summary: A platform independent file lock. Project-URL: Documentation, https://py-filelock.readthedocs.io Project-URL: Homepage, https://github.com/tox-dev/py-filelock @@ -22,15 +22,17 @@ Classifier: Topic :: System Requires-Python: >=3.7 Provides-Extra: docs -Requires-Dist: furo>=2022.12.7; extra == 'docs' -Requires-Dist: sphinx-autodoc-typehints!=1.23.4,>=1.22; extra == 'docs' +Requires-Dist: furo>=2023.3.27; extra == 'docs' +Requires-Dist: sphinx-autodoc-typehints!=1.23.4,>=1.23; extra == 'docs' Requires-Dist: sphinx>=6.1.3; extra == 'docs' Provides-Extra: testing Requires-Dist: covdefaults>=2.3; extra == 'testing' -Requires-Dist: coverage>=7.2.1; extra == 'testing' +Requires-Dist: coverage>=7.2.3; extra == 'testing' +Requires-Dist: diff-cover>=7.5; extra == 'testing' Requires-Dist: pytest-cov>=4; extra == 'testing' +Requires-Dist: pytest-mock>=3.10; extra == 'testing' Requires-Dist: pytest-timeout>=2.1; extra == 'testing' -Requires-Dist: pytest>=7.2.2; extra == 'testing' +Requires-Dist: pytest>=7.3.1; extra == 'testing' Description-Content-Type: text/markdown # py-filelock diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/filelock-3.9.1/pyproject.toml new/filelock-3.12.0/pyproject.toml --- old/filelock-3.9.1/pyproject.toml 2020-02-02 01:00:00.000000000 +0100 +++ new/filelock-3.12.0/pyproject.toml 2020-02-02 01:00:00.000000000 +0100 @@ -2,7 +2,7 @@ build-backend = "hatchling.build" requires = [ "hatch-vcs>=0.3", - "hatchling>=1.13", + "hatchling>=1.14", ] [project] @@ -35,15 +35,17 @@ "version", ] optional-dependencies.docs = [ - "furo>=2022.12.7", + "furo>=2023.3.27", "sphinx>=6.1.3", - "sphinx-autodoc-typehints!=1.23.4,>=1.22", + "sphinx-autodoc-typehints!=1.23.4,>=1.23", ] optional-dependencies.testing = [ "covdefaults>=2.3", - "coverage>=7.2.1", - "pytest>=7.2.2", + "coverage>=7.2.3", + "diff-cover>=7.5", + "pytest>=7.3.1", "pytest-cov>=4", + "pytest-mock>=3.10", "pytest-timeout>=2.1", ] urls.Documentation = "https://py-filelock.readthedocs.io" @@ -56,6 +58,21 @@ build.targets.sdist.include = ["/src", "/tests"] version.source = "vcs" +[tool.black] +line-length = 120 + +[tool.isort] +profile = "black" +known_first_party = ["filelock"] +add_imports = ["from __future__ import annotations"] + +[tool.flake8] +max-complexity = 22 +max-line-length = 120 +unused-arguments-ignore-abstract-functions = true +noqa-require-code = true +dictionaries = ["en_US", "python", "technical", "django"] + [tool.coverage] html.show_contexts = true html.skip_covered = false @@ -65,13 +82,6 @@ run.parallel = true run.plugins = ["covdefaults"] -[tool.black] -line-length = 120 - -[tool.isort] -profile = "black" -known_first_party = ["filelock"] - [tool.mypy] python_version = "3.11" show_error_codes = true @@ -80,10 +90,3 @@ [tool.pep8] max-line-length = "120" - -[tool.flake8] -max-complexity = 22 -max-line-length = 120 -unused-arguments-ignore-abstract-functions = true -noqa-require-code = true -dictionaries = ["en_US", "python", "technical", "django"] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/filelock-3.9.1/src/filelock/__init__.py new/filelock-3.12.0/src/filelock/__init__.py --- old/filelock-3.9.1/src/filelock/__init__.py 2020-02-02 01:00:00.000000000 +0100 +++ new/filelock-3.12.0/src/filelock/__init__.py 2020-02-02 01:00:00.000000000 +0100 @@ -32,11 +32,10 @@ if warnings is not None: warnings.warn("only soft file lock is available", stacklevel=2) -#: Alias for the lock, which should be used for the current platform. On Windows, this is an alias for -# :class:`WindowsFileLock`, on Unix for :class:`UnixFileLock` and otherwise for :class:`SoftFileLock`. if TYPE_CHECKING: FileLock = SoftFileLock else: + #: Alias for the lock, which should be used for the current platform. FileLock = _FileLock diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/filelock-3.9.1/src/filelock/_api.py new/filelock-3.12.0/src/filelock/_api.py --- old/filelock-3.9.1/src/filelock/_api.py 2020-02-02 01:00:00.000000000 +0100 +++ new/filelock-3.12.0/src/filelock/_api.py 2020-02-02 01:00:00.000000000 +0100 @@ -6,7 +6,8 @@ import time import warnings from abc import ABC, abstractmethod -from threading import Lock +from dataclasses import dataclass +from threading import local from types import TracebackType from typing import Any @@ -36,10 +37,47 @@ self.lock.release() +@dataclass +class FileLockContext: + """ + A dataclass which holds the context for a ``BaseFileLock`` object. + """ + + # The context is held in a separate class to allow optional use of thread local storage via the + # ThreadLocalFileContext class. + + #: The path to the lock file. + lock_file: str + + #: The default timeout value. + timeout: float + + #: The mode for the lock files + mode: int + + #: 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 + + #: The lock counter is used for implementing the nested locking mechanism. + lock_counter: int = 0 # When the lock is acquired is increased and the lock is only released, when this value is 0 + + +class ThreadLocalFileContext(FileLockContext, local): + """ + A thread local version of the ``FileLockContext`` class. + """ + + class BaseFileLock(ABC, contextlib.ContextDecorator): """Abstract base class for a file lock object.""" - def __init__(self, lock_file: str | os.PathLike[Any], timeout: float = -1) -> None: + def __init__( + self, + lock_file: str | os.PathLike[Any], + timeout: float = -1, + mode: int = 0o644, + thread_local: bool = True, + ) -> None: """ Create a new lock object. @@ -47,28 +85,29 @@ :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. - """ - # The path to the lock file. - self._lock_file: str = os.fspath(lock_file) - - # The file descriptor for the *_lock_file* as it is returned by the os.open() function. - # This file lock is only NOT None, if the object currently holds the lock. - self._lock_file_fd: int | None = None - - # The default timeout value. - self._timeout: float = timeout - - # We use this lock primarily for the lock counter. - self._thread_lock: Lock = Lock() - - # The lock counter is used for implementing the nested locking mechanism. Whenever the lock is acquired, the - # counter is increased and the lock is only released, when this value is 0 again. - self._lock_counter: int = 0 + :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. + """ + self._is_thread_local = thread_local + + # 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, + } + self._context: FileLockContext = (ThreadLocalFileContext if thread_local else FileLockContext)(**kwargs) + + def is_thread_local(self) -> bool: + """:return: a flag indicating if this lock is thread local or not""" + return self._is_thread_local @property def lock_file(self) -> str: """:return: path to the lock file""" - return self._lock_file + return self._context.lock_file @property def timeout(self) -> float: @@ -77,7 +116,7 @@ .. versionadded:: 2.0.0 """ - return self._timeout + return self._context.timeout @timeout.setter def timeout(self, value: float | str) -> None: @@ -86,16 +125,16 @@ :param value: the new value, in seconds """ - self._timeout = float(value) + self._context.timeout = float(value) @abstractmethod def _acquire(self) -> None: - """If the file lock could be acquired, self._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.""" raise NotImplementedError @abstractmethod def _release(self) -> None: - """Releases the lock and sets self._lock_file_fd to None.""" + """Releases the lock and sets self._context.lock_file_fd to None.""" raise NotImplementedError @property @@ -108,7 +147,14 @@ This was previously a method and is now a property. """ - return self._lock_file_fd is not None + return self._context.lock_file_fd is not None + + @property + def lock_counter(self) -> int: + """ + :return: The number of times this lock has been acquired (but not yet released). + """ + return self._context.lock_counter def acquire( self, @@ -126,7 +172,7 @@ :param poll_interval: interval of trying to acquire the lock file :param poll_intervall: deprecated, kept for backwards compatibility, use ``poll_interval`` instead :param blocking: defaults to True. If False, function will return immediately if it cannot obtain a lock on the - first attempt. Otherwise this method will block until the timeout expires or the lock is acquired. + first attempt. Otherwise, this method will block until the timeout expires or the lock is acquired. :raises Timeout: if fails to acquire lock within the timeout period :return: a context object that will unlock the file when the context is exited @@ -151,7 +197,7 @@ """ # Use the default timeout, if no timeout is provided. if timeout is None: - timeout = self.timeout + timeout = self._context.timeout if poll_intervall is not None: msg = "use poll_interval instead of poll_intervall" @@ -159,35 +205,31 @@ poll_interval = poll_intervall # Increment the number right at the beginning. We can still undo it, if something fails. - with self._thread_lock: - self._lock_counter += 1 + self._context.lock_counter += 1 lock_id = id(self) - lock_filename = self._lock_file + lock_filename = self.lock_file start_time = time.perf_counter() try: while True: - with self._thread_lock: - if not self.is_locked: - _LOGGER.debug("Attempting to acquire lock %s on %s", lock_id, lock_filename) - self._acquire() - + if not self.is_locked: + _LOGGER.debug("Attempting to acquire lock %s on %s", lock_id, lock_filename) + self._acquire() if self.is_locked: _LOGGER.debug("Lock %s acquired on %s", lock_id, lock_filename) break elif blocking is False: _LOGGER.debug("Failed to immediately acquire lock %s on %s", lock_id, lock_filename) - raise Timeout(self._lock_file) + raise Timeout(lock_filename) elif 0 <= timeout < time.perf_counter() - start_time: _LOGGER.debug("Timeout on acquiring lock %s on %s", lock_id, lock_filename) - raise Timeout(self._lock_file) + raise Timeout(lock_filename) else: msg = "Lock %s not acquired on %s, waiting %s seconds ..." _LOGGER.debug(msg, lock_id, lock_filename, poll_interval) time.sleep(poll_interval) except BaseException: # Something did go wrong, so decrement the counter. - with self._thread_lock: - self._lock_counter = max(0, self._lock_counter - 1) + self._context.lock_counter = max(0, self._context.lock_counter - 1) raise return AcquireReturnProxy(lock=self) @@ -198,17 +240,16 @@ :param force: If true, the lock counter is ignored and the lock is released in every case/ """ - with self._thread_lock: - if self.is_locked: - self._lock_counter -= 1 - - if self._lock_counter == 0 or force: - lock_id, lock_filename = id(self), self._lock_file - - _LOGGER.debug("Attempting to release lock %s on %s", lock_id, lock_filename) - self._release() - self._lock_counter = 0 - _LOGGER.debug("Lock %s released on %s", lock_id, lock_filename) + if self.is_locked: + self._context.lock_counter -= 1 + + if self._context.lock_counter == 0 or force: + lock_id, lock_filename = id(self), self.lock_file + + _LOGGER.debug("Attempting to release lock %s on %s", lock_id, lock_filename) + self._release() + self._context.lock_counter = 0 + _LOGGER.debug("Lock %s released on %s", lock_id, lock_filename) def __enter__(self) -> BaseFileLock: """ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/filelock-3.9.1/src/filelock/_error.py new/filelock-3.12.0/src/filelock/_error.py --- old/filelock-3.9.1/src/filelock/_error.py 2020-02-02 01:00:00.000000000 +0100 +++ new/filelock-3.12.0/src/filelock/_error.py 2020-02-02 01:00:00.000000000 +0100 @@ -1,15 +1,28 @@ from __future__ import annotations +from typing import Any + class Timeout(TimeoutError): """Raised when the lock could not be acquired in *timeout* seconds.""" def __init__(self, lock_file: str) -> None: - #: The path of the file lock. - self.lock_file = lock_file + super().__init__() + self._lock_file = lock_file + + def __reduce__(self) -> str | tuple[Any, ...]: + return self.__class__, (self._lock_file,) # Properly pickle the exception def __str__(self) -> str: - return f"The file lock '{self.lock_file}' could not be acquired." + return f"The file lock '{self._lock_file}' could not be acquired." + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.lock_file!r})" + + @property + def lock_file(self) -> str: + """:return: The path of the file lock.""" + return self._lock_file __all__ = [ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/filelock-3.9.1/src/filelock/_soft.py new/filelock-3.12.0/src/filelock/_soft.py --- old/filelock-3.9.1/src/filelock/_soft.py 2020-02-02 01:00:00.000000000 +0100 +++ new/filelock-3.12.0/src/filelock/_soft.py 2020-02-02 01:00:00.000000000 +0100 @@ -2,42 +2,40 @@ import os import sys -from errno import EACCES, EEXIST, ENOENT +from errno import EACCES, EEXIST from ._api import BaseFileLock -from ._util import raise_on_exist_ro_file +from ._util import raise_on_not_writable_file class SoftFileLock(BaseFileLock): """Simply watches the existence of the lock file.""" def _acquire(self) -> None: - raise_on_exist_ro_file(self._lock_file) + raise_on_not_writable_file(self.lock_file) # first check for exists and read-only mode as the open will mask this case as EEXIST - mode = ( + flags = ( os.O_WRONLY # open for writing only | os.O_CREAT | os.O_EXCL # together with above raise EEXIST if the file specified by filename exists | os.O_TRUNC # truncate the file to zero byte ) try: - fd = os.open(self._lock_file, mode) - except OSError as exception: - if exception.errno == EEXIST: # expected if cannot lock - pass - elif exception.errno == ENOENT: # No such file or directory - parent directory is missing + file_handler = os.open(self.lock_file, flags, self._context.mode) + except OSError as exception: # re-raise unless expected exception + if not ( + exception.errno == EEXIST # lock already exist + or (exception.errno == EACCES and sys.platform == "win32") # has no access to this lock + ): # pragma: win32 no cover raise - elif exception.errno == EACCES and sys.platform != "win32": # pragma: win32 no cover - # Permission denied - parent dir is R/O - raise # note windows does not allow you to make a folder r/o only files else: - self._lock_file_fd = fd + self._context.lock_file_fd = file_handler def _release(self) -> None: - os.close(self._lock_file_fd) # type: ignore # the lock file is definitely not None - self._lock_file_fd = None + os.close(self._context.lock_file_fd) # type: ignore # the lock file is definitely not None + self._context.lock_file_fd = None try: - os.remove(self._lock_file) + os.remove(self.lock_file) except OSError: # the file is already deleted and that's what we want pass diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/filelock-3.9.1/src/filelock/_unix.py new/filelock-3.12.0/src/filelock/_unix.py --- old/filelock-3.9.1/src/filelock/_unix.py 2020-02-02 01:00:00.000000000 +0100 +++ new/filelock-3.12.0/src/filelock/_unix.py 2020-02-02 01:00:00.000000000 +0100 @@ -2,6 +2,7 @@ import os import sys +from errno import ENOSYS from typing import cast from ._api import BaseFileLock @@ -31,21 +32,27 @@ """Uses the :func:`fcntl.flock` to hard lock the lock file on unix systems.""" def _acquire(self) -> None: - open_mode = os.O_RDWR | os.O_CREAT | os.O_TRUNC - fd = os.open(self._lock_file, open_mode) + open_flags = os.O_RDWR | os.O_CREAT | os.O_TRUNC + fd = os.open(self.lock_file, open_flags, self._context.mode) + try: + os.fchmod(fd, self._context.mode) + except PermissionError: + pass # This locked is not owned by this UID try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - except OSError: + except OSError as exception: os.close(fd) + if exception.errno == ENOSYS: # NotImplemented error + raise NotImplementedError("FileSystem does not appear to support flock; user SoftFileLock instead") else: - self._lock_file_fd = fd + self._context.lock_file_fd = fd def _release(self) -> None: # Do not remove the lockfile: # https://github.com/tox-dev/py-filelock/issues/31 # https://stackoverflow.com/questions/17708885/flock-removing-locked-file-without-race-condition - fd = cast(int, self._lock_file_fd) - self._lock_file_fd = None + fd = cast(int, self._context.lock_file_fd) + self._context.lock_file_fd = None fcntl.flock(fd, fcntl.LOCK_UN) os.close(fd) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/filelock-3.9.1/src/filelock/_util.py new/filelock-3.12.0/src/filelock/_util.py --- old/filelock-3.9.1/src/filelock/_util.py 2020-02-02 01:00:00.000000000 +0100 +++ new/filelock-3.12.0/src/filelock/_util.py 2020-02-02 01:00:00.000000000 +0100 @@ -2,9 +2,18 @@ import os import stat +import sys +from errno import EACCES, EISDIR -def raise_on_exist_ro_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. + 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: file_stat = os.stat(filename) # use stat to do exists + can write to check without race condition except OSError: @@ -12,9 +21,17 @@ if file_stat.st_mtime != 0: # if os.stat returns but modification is zero that's an invalid os.stat - ignore it if not (file_stat.st_mode & stat.S_IWUSR): - raise PermissionError(f"Permission denied: {filename!r}") + raise PermissionError(EACCES, "Permission denied", filename) + + if stat.S_ISDIR(file_stat.st_mode): + if sys.platform == "win32": # pragma: win32 cover + # On Windows, this is PermissionError + raise PermissionError(EACCES, "Permission denied", filename) + else: # pragma: win32 no cover + # On linux / macOS, this is IsADirectoryError + raise IsADirectoryError(EISDIR, "Is a directory", filename) __all__ = [ - "raise_on_exist_ro_file", + "raise_on_not_writable_file", ] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/filelock-3.9.1/src/filelock/_windows.py new/filelock-3.12.0/src/filelock/_windows.py --- old/filelock-3.9.1/src/filelock/_windows.py 2020-02-02 01:00:00.000000000 +0100 +++ new/filelock-3.12.0/src/filelock/_windows.py 2020-02-02 01:00:00.000000000 +0100 @@ -2,46 +2,48 @@ import os import sys -from errno import ENOENT +from errno import EACCES from typing import cast from ._api import BaseFileLock -from ._util import raise_on_exist_ro_file +from ._util import raise_on_not_writable_file if sys.platform == "win32": # pragma: win32 cover import msvcrt class WindowsFileLock(BaseFileLock): - """Uses the :func:`msvcrt.locking` function to hard lock the lock file on windows systems.""" + """Uses the :func:`msvcrt.locking` function to hard lock the lock file on Windows systems.""" def _acquire(self) -> None: - raise_on_exist_ro_file(self._lock_file) - mode = ( + raise_on_not_writable_file(self.lock_file) + flags = ( os.O_RDWR # open for read and write | os.O_CREAT # create file if not exists - | os.O_TRUNC # truncate file if not empty + | os.O_TRUNC # truncate file if not empty ) try: - fd = os.open(self._lock_file, mode) + fd = os.open(self.lock_file, flags, self._context.mode) except OSError as exception: - if exception.errno == ENOENT: # No such file or directory + if exception.errno != EACCES: # has no access to this lock raise else: try: msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) - except OSError: - os.close(fd) + except OSError as exception: + os.close(fd) # close file first + if exception.errno != EACCES: # file is already locked + raise else: - self._lock_file_fd = fd + self._context.lock_file_fd = fd def _release(self) -> None: - fd = cast(int, self._lock_file_fd) - self._lock_file_fd = None + fd = cast(int, self._context.lock_file_fd) + self._context.lock_file_fd = None msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) os.close(fd) try: - os.remove(self._lock_file) + os.remove(self.lock_file) # Probably another instance of the application hat acquired the file lock. except OSError: pass @@ -49,7 +51,7 @@ else: # pragma: win32 no cover class WindowsFileLock(BaseFileLock): - """Uses the :func:`msvcrt.locking` function to hard lock the lock file on windows systems.""" + """Uses the :func:`msvcrt.locking` function to hard lock the lock file on Windows systems.""" def _acquire(self) -> None: raise NotImplementedError diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/filelock-3.9.1/src/filelock/version.py new/filelock-3.12.0/src/filelock/version.py --- old/filelock-3.9.1/src/filelock/version.py 2020-02-02 01:00:00.000000000 +0100 +++ new/filelock-3.12.0/src/filelock/version.py 2020-02-02 01:00:00.000000000 +0100 @@ -1,4 +1,4 @@ # file generated by setuptools_scm # don't change, don't track in version control -__version__ = version = '3.9.1' -__version_tuple__ = version_tuple = (3, 9, 1) +__version__ = version = '3.12.0' +__version_tuple__ = version_tuple = (3, 12, 0) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/filelock-3.9.1/tests/test_error.py new/filelock-3.12.0/tests/test_error.py --- old/filelock-3.9.1/tests/test_error.py 1970-01-01 01:00:00.000000000 +0100 +++ new/filelock-3.12.0/tests/test_error.py 2020-02-02 01:00:00.000000000 +0100 @@ -0,0 +1,30 @@ +from __future__ import annotations + +import pickle + +from filelock import Timeout + + +def test_timeout_str() -> None: + timeout = Timeout("/path/to/lock") + assert str(timeout) == "The file lock '/path/to/lock' could not be acquired." + + +def test_timeout_repr() -> None: + timeout = Timeout("/path/to/lock") + assert repr(timeout) == "Timeout('/path/to/lock')" + + +def test_timeout_lock_file() -> None: + timeout = Timeout("/path/to/lock") + assert timeout.lock_file == "/path/to/lock" + + +def test_timeout_pickle() -> None: + timeout = Timeout("/path/to/lock") + timeout_loaded = pickle.loads(pickle.dumps(timeout)) + + assert timeout.__class__ == timeout_loaded.__class__ + assert str(timeout) == str(timeout_loaded) + assert repr(timeout) == repr(timeout_loaded) + assert timeout.lock_file == timeout_loaded.lock_file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/filelock-3.9.1/tests/test_filelock.py new/filelock-3.12.0/tests/test_filelock.py --- old/filelock-3.9.1/tests/test_filelock.py 2020-02-02 01:00:00.000000000 +0100 +++ new/filelock-3.12.0/tests/test_filelock.py 2020-02-02 01:00:00.000000000 +0100 @@ -2,17 +2,22 @@ import inspect import logging +import os import sys import threading +from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager +from errno import ENOSYS from inspect import getframeinfo, stack from pathlib import Path, PurePath -from stat import S_IWGRP, S_IWOTH, S_IWUSR +from stat import S_IWGRP, S_IWOTH, S_IWUSR, filemode from types import TracebackType from typing import Callable, Iterator, Tuple, Type, Union +from uuid import uuid4 import pytest from _pytest.logging import LogCaptureFixture +from pytest_mock import MockerFixture from filelock import ( BaseFileLock, @@ -78,6 +83,10 @@ @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.skipif(sys.platform == "win32", reason="Windows does not have read only folders") +@pytest.mark.skipif( + sys.platform != "win32" and os.geteuid() == 0, # noqa: SC200 + reason="Cannot make a read only file (that the current user: root can't read)", +) def test_ro_folder(lock_type: type[BaseFileLock], tmp_path_ro: Path) -> None: lock = lock_type(str(tmp_path_ro / "a")) with pytest.raises(PermissionError, match="Permission denied"): @@ -93,18 +102,46 @@ @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) +@pytest.mark.skipif( + sys.platform != "win32" and os.geteuid() == 0, # noqa: SC200 + reason="Cannot make a read only file (that the current user: root can't read)", +) def test_ro_file(lock_type: type[BaseFileLock], tmp_file_ro: Path) -> None: lock = lock_type(str(tmp_file_ro)) with pytest.raises(PermissionError, match="Permission denied"): lock.acquire() +WindowsOnly = pytest.mark.skipif(sys.platform != "win32", reason="Windows only") + + @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) -def test_missing_directory(lock_type: type[BaseFileLock], tmp_path_ro: Path) -> None: - lock_path = tmp_path_ro / "a" / "b" - lock = lock_type(str(lock_path)) +@pytest.mark.parametrize( + ("expected_error", "match", "bad_lock_file"), + [ + pytest.param(FileNotFoundError, "No such file or directory:", "a/b", id="non_existent_directory"), + pytest.param(FileNotFoundError, "No such file or directory:", "", id="blank_filename"), + pytest.param(ValueError, "embedded null (byte|character)", "\0", id="null_byte"), + pytest.param( + PermissionError if sys.platform == "win32" else IsADirectoryError, + "Permission denied:" if sys.platform == "win32" else "Is a directory", + ".", + id="current_directory", + ), + ] + + [pytest.param(OSError, "Invalid argument", i, id=f"invalid_{i}", marks=WindowsOnly) for i in '<>:"|?*\a'] + + [pytest.param(PermissionError, "Permission denied:", i, id=f"permission_{i}", marks=WindowsOnly) for i in "/\\"], +) +@pytest.mark.timeout(5) # timeout in case of infinite loop +def test_bad_lock_file( + lock_type: type[BaseFileLock], + expected_error: type[Exception], + match: str, + bad_lock_file: str, +) -> None: + lock = lock_type(bad_lock_file) - with pytest.raises(OSError, match="No such file or directory:"): + with pytest.raises(expected_error, match=match): lock.acquire() @@ -334,8 +371,8 @@ with lock as lock_1: assert lock is lock_1 assert lock.is_locked - raise Exception - except Exception: + raise ValueError + except ValueError: assert not lock.is_locked @@ -349,8 +386,8 @@ with lock.acquire() as lock_1: assert lock is lock_1 assert lock.is_locked - raise Exception - except Exception: + raise ValueError + except ValueError: assert not lock.is_locked @@ -382,9 +419,8 @@ def test_cleanup_soft_lock(tmp_path: Path) -> None: # tests if the lock file is removed after use lock_path = tmp_path / "a" - lock = SoftFileLock(str(lock_path)) - with lock: + with SoftFileLock(lock_path): assert lock_path.exists() assert not lock_path.exists() @@ -396,9 +432,9 @@ with pytest.deprecated_call(match="use poll_interval instead of poll_intervall") as checker: lock.acquire(poll_intervall=0.05) # the deprecation warning will be captured by the checker - frameinfo = getframeinfo(stack()[0][0]) # get frameinfo of current file and lineno (+1 than the above lineno) + frame_info = getframeinfo(stack()[0][0]) # get frame info of current file and lineno (+1 than the above lineno) for warning in checker: - if warning.filename == frameinfo.filename and warning.lineno + 1 == frameinfo.lineno: # pragma: no cover + if warning.filename == frame_info.filename and warning.lineno + 1 == frame_info.lineno: # pragma: no cover break else: # pragma: no cover pytest.fail("No warnings of stacklevel=2 matching.") @@ -418,15 +454,178 @@ assert not lock.is_locked +def test_lock_mode(tmp_path: Path) -> None: + # test file lock permissions are independent of umask + lock_path = tmp_path / "a.lock" + lock = FileLock(str(lock_path), mode=0o666) + + # set umask so permissions can be anticipated + initial_umask = os.umask(0o022) + try: + lock.acquire() + assert lock.is_locked + + mode = filemode(os.stat(lock_path).st_mode) + assert mode == "-rw-rw-rw-" + finally: + os.umask(initial_umask) + + lock.release() + + +def test_lock_mode_soft(tmp_path: Path) -> None: + # test soft lock permissions are dependent of umask + lock_path = tmp_path / "a.lock" + lock = SoftFileLock(str(lock_path), mode=0o666) + + # set umask so permissions can be anticipated + initial_umask = os.umask(0o022) + try: + lock.acquire() + assert lock.is_locked + + mode = filemode(os.stat(lock_path).st_mode) + if sys.platform == "win32": + assert mode == "-rw-rw-rw-" + else: + assert mode == "-rw-r--r--" + finally: + os.umask(initial_umask) + + lock.release() + + +def test_umask(tmp_path: Path) -> None: + lock_path = tmp_path / "a.lock" + lock = FileLock(str(lock_path), mode=0o666) + + initial_umask = os.umask(0) + os.umask(initial_umask) + + lock.acquire() + assert lock.is_locked + + current_umask = os.umask(0) + os.umask(current_umask) + assert initial_umask == current_umask + + lock.release() + + +def test_umask_soft(tmp_path: Path) -> None: + lock_path = tmp_path / "a.lock" + lock = SoftFileLock(str(lock_path), mode=0o666) + + initial_umask = os.umask(0) + os.umask(initial_umask) + + lock.acquire() + assert lock.is_locked + + current_umask = os.umask(0) + os.umask(current_umask) + assert initial_umask == current_umask + + lock.release() + + def test_wrong_platform(tmp_path: Path) -> None: assert not inspect.isabstract(UnixFileLock) assert not inspect.isabstract(WindowsFileLock) assert inspect.isabstract(BaseFileLock) lock_type = UnixFileLock if sys.platform == "win32" else WindowsFileLock - lock = lock_type(str(tmp_path / "lockfile")) + lock = lock_type(tmp_path / "lockfile") with pytest.raises(NotImplementedError): lock.acquire() with pytest.raises(NotImplementedError): lock._release() + + +@pytest.mark.skipif(sys.platform == "win32", reason="flock not run on windows") +def test_flock_not_implemented_unix(tmp_path: Path, mocker: MockerFixture) -> None: + mocker.patch("fcntl.flock", side_effect=OSError(ENOSYS, "mock error")) + with pytest.raises(NotImplementedError): + with FileLock(tmp_path / "a.lock"): + pass + + +def test_soft_errors(tmp_path: Path, mocker: MockerFixture) -> None: + mocker.patch("os.open", side_effect=OSError(ENOSYS, "mock error")) + with pytest.raises(OSError, match="mock error"): + SoftFileLock(tmp_path / "a.lock").acquire() + + +def _check_file_read_write(txt_file: Path) -> None: + for _ in range(3): + uuid = str(uuid4()) + txt_file.write_text(uuid) + assert txt_file.read_text() == uuid + + +@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) +def test_thrashing_with_thread_pool_passing_lock_to_threads(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: + def mess_with_file(lock_: BaseFileLock) -> None: + with lock_: + _check_file_read_write(txt_file) + + lock_file, txt_file = tmp_path / "test.txt.lock", tmp_path / "test.txt" + lock = lock_type(lock_file) + results = [] + with ThreadPoolExecutor() as executor: + for _ in range(100): + results.append(executor.submit(mess_with_file, lock)) + + assert all(r.result() is None for r in results) + + +@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) +def test_thrashing_with_thread_pool_global_lock(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: + def mess_with_file() -> None: + with lock: + _check_file_read_write(txt_file) + + lock_file, txt_file = tmp_path / "test.txt.lock", tmp_path / "test.txt" + lock = lock_type(lock_file) + results = [] + with ThreadPoolExecutor() as executor: + for _ in range(100): + results.append(executor.submit(mess_with_file)) + + assert all(r.result() is None for r in results) + + +@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) +def test_thrashing_with_thread_pool_lock_recreated_in_each_thread( + tmp_path: Path, + lock_type: type[BaseFileLock], +) -> None: + def mess_with_file() -> None: + with lock_type(lock_file): + _check_file_read_write(txt_file) + + lock_file, txt_file = tmp_path / "test.txt.lock", tmp_path / "test.txt" + results = [] + with ThreadPoolExecutor() as executor: + for _ in range(100): + results.append(executor.submit(mess_with_file)) + + assert all(r.result() is None for r in results) + + +@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) +def test_lock_can_be_non_thread_local( + tmp_path: Path, + lock_type: type[BaseFileLock], +) -> None: + lock = lock_type(tmp_path / "test.lock", thread_local=False) + + for _ in range(2): + thread = threading.Thread(target=lock.acquire, kwargs={"timeout": 2}) + thread.start() + thread.join() + + assert lock.lock_counter == 2 + + lock.release(force=True)