diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 3472057..39d8572 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -29,16 +29,13 @@ jobs: - 3.8 - 3.7 - 3.6 - - 3.5 - pypy3 - - 2.7 - - pypy2 steps: - name: Setup python for tox uses: actions/setup-python@v2 with: - python-version: 3.9 + python-version: 3.10.0-rc.2 - name: Install tox run: python -m pip install tox - uses: actions/checkout@v2 @@ -87,6 +84,7 @@ jobs: - windows-latest tox_env: - dev + - type - docs - readme exclude: @@ -95,10 +93,10 @@ jobs: - uses: actions/checkout@v2 with: fetch-depth: 0 - - name: Setup Python 3.9 + - name: Setup Python 3.10.0-rc.2 uses: actions/setup-python@v2 with: - python-version: 3.9 + python-version: 3.10.0-rc.2 - name: Install tox run: python -m pip install tox - name: Setup test suite @@ -114,7 +112,7 @@ jobs: - name: Setup python to build package uses: actions/setup-python@v2 with: - python-version: 3.9 + python-version: 3.10.0-rc.2 - name: Install https://pypi.org/project/build run: python -m pip install build - uses: actions/checkout@v2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6ff2515..5aaaff4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,6 +15,7 @@ repos: rev: v2.28.0 hooks: - id: pyupgrade + args: [ "--py36-plus" ] - repo: https://github.com/PyCQA/isort rev: 5.9.3 hooks: @@ -42,7 +43,7 @@ repos: rev: v1.17.0 hooks: - id: setup-cfg-fmt - args: [ --min-py3-version, "3.5", "--max-py-version", "3.10" ] + args: [ --min-py3-version, "3.6", "--max-py-version", "3.10" ] - repo: https://github.com/PyCQA/flake8 rev: 3.9.2 hooks: diff --git a/codecov.yml b/codecov.yml index 69cb760..5f91842 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1 +1,6 @@ +coverage: + status: + patch: + default: + informational: true comment: false diff --git a/setup.cfg b/setup.cfg index 2fa98a9..86ded37 100644 --- a/setup.cfg +++ b/setup.cfg @@ -14,10 +14,8 @@ classifiers = License :: Public Domain Operating System :: OS Independent Programming Language :: Python - Programming Language :: Python :: 2 - Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 - Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 @@ -33,7 +31,7 @@ project_urls = [options] packages = find: -python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* +python_requires = >=3.6 package_dir = =src zip_safe = True @@ -47,16 +45,18 @@ docs = sphinx>=4.1 sphinx-autodoc-typehints>=1.12 testing = + covdefaults>=1.2.0 coverage>=4 pytest>=4 pytest-cov pytest-timeout>=1.4.2 -[bdist_wheel] -universal = true +[options.package_data] +filelock = py.typed -[coverage:report] -show_missing = True +[coverage:run] +plugins = covdefaults +parallel = true [coverage:paths] source = @@ -67,6 +67,12 @@ source = */src *\src -[coverage:run] -branch = true -parallel = true +[coverage:report] +fail_under = 88 + +[coverage:html] +show_contexts = true +skip_covered = false + +[coverage:covdefaults] +subtract_omit = */.tox/* diff --git a/src/filelock/__init__.py b/src/filelock/__init__.py index 5c8d990..b0f5052 100644 --- a/src/filelock/__init__.py +++ b/src/filelock/__init__.py @@ -7,6 +7,7 @@ """ import sys import warnings +from typing import Type from ._api import AcquireReturnProxy, BaseFileLock from ._error import Timeout @@ -16,30 +17,31 @@ from .version import version #: version of the project as a string -__version__ = version +__version__: str = version -if sys.platform == "win32": - _FileLock = WindowsFileLock -elif has_fcntl: - _FileLock = UnixFileLock -else: - _FileLock = SoftFileLock - if warnings is not None: - warnings.warn("only soft file lock is available") +if sys.platform == "win32": # pragma: win32 cover + _FileLock: Type[BaseFileLock] = WindowsFileLock +else: # pragma: win32 no cover + if has_fcntl: + _FileLock: Type[BaseFileLock] = UnixFileLock + else: + _FileLock = SoftFileLock + if warnings is not None: + warnings.warn("only soft file lock is available") #: 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`. -FileLock = _FileLock +FileLock: Type[BaseFileLock] = _FileLock __all__ = [ "__version__", "FileLock", "SoftFileLock", - "WindowsFileLock", + "Timeout", "UnixFileLock", + "WindowsFileLock", "BaseFileLock", - "Timeout", "AcquireReturnProxy", ] diff --git a/src/filelock/_api.py b/src/filelock/_api.py index 73ddd4c..382f5cb 100644 --- a/src/filelock/_api.py +++ b/src/filelock/_api.py @@ -1,6 +1,9 @@ import logging import time +from abc import ABC, abstractmethod from threading import Lock +from types import TracebackType +from typing import Optional, Type, Union from ._error import Timeout @@ -11,23 +14,28 @@ # This is a helper class which is returned by :meth:`BaseFileLock.acquire` and wraps the lock to make sure __enter__ # 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(object): +class AcquireReturnProxy: """A context aware object that will release the lock file when exiting.""" - def __init__(self, lock): + def __init__(self, lock: "BaseFileLock") -> None: self.lock = lock - def __enter__(self): + def __enter__(self) -> "BaseFileLock": return self.lock - def __exit__(self, exc_type, exc_value, traceback): # noqa: U100 + def __exit__( + self, + exc_type: Optional[Type[BaseException]], # noqa: U100 + exc_value: Optional[BaseException], # noqa: U100 + traceback: Optional[TracebackType], # noqa: U100 + ) -> None: self.lock.release() -class BaseFileLock(object): +class BaseFileLock(ABC): """Abstract base class for a file lock object.""" - def __init__(self, lock_file, timeout=-1): + def __init__(self, lock_file: str, timeout: float = -1) -> None: """ Create a new lock object. @@ -37,29 +45,29 @@ def __init__(self, lock_file, timeout=-1): 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 = lock_file + self._lock_file: str = 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 = None + self._lock_file_fd: Optional[int] = None # The default timeout value. - self.timeout = timeout + self.timeout: float = timeout # We use this lock primarily for the lock counter. - self._thread_lock = Lock() + 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 = 0 + self._lock_counter: int = 0 @property - def lock_file(self): + def lock_file(self) -> str: """:return: path to the lock file""" return self._lock_file @property - def timeout(self): + def timeout(self) -> float: """ :return: the default timeout value @@ -68,7 +76,7 @@ def timeout(self): return self._timeout @timeout.setter - def timeout(self, value): + def timeout(self, value: Union[float, str]) -> None: """ Change the default timeout value. @@ -76,16 +84,18 @@ def timeout(self, value): """ self._timeout = float(value) - def _acquire(self): + @abstractmethod + def _acquire(self) -> None: """If the file lock could be acquired, self._lock_file_fd holds the file descriptor of the lock file.""" raise NotImplementedError - def _release(self): + @abstractmethod + def _release(self) -> None: """Releases the lock and sets self._lock_file_fd to None.""" raise NotImplementedError @property - def is_locked(self): + def is_locked(self) -> bool: """ :return: A boolean indicating if the lock file is holding the lock currently. @@ -96,7 +106,7 @@ def is_locked(self): """ return self._lock_file_fd is not None - def acquire(self, timeout=None, poll_intervall=0.05): + def acquire(self, timeout: Optional[float] = None, poll_intervall: float = 0.05) -> AcquireReturnProxy: """ Try to acquire the file lock. @@ -160,7 +170,7 @@ def acquire(self, timeout=None, poll_intervall=0.05): raise return AcquireReturnProxy(lock=self) - def release(self, force=False): + def release(self, force: bool = False) -> None: """ 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. @@ -180,7 +190,7 @@ def release(self, force=False): self._lock_counter = 0 _LOGGER.debug("Lock %s released on %s", lock_id, lock_filename) - def __enter__(self): + def __enter__(self) -> "BaseFileLock": """ Acquire the lock. @@ -189,7 +199,12 @@ def __enter__(self): self.acquire() return self - def __exit__(self, exc_type, exc_value, traceback): # noqa: U100 + def __exit__( + self, + exc_type: Optional[Type[BaseException]], # noqa: U100 + exc_value: Optional[BaseException], # noqa: U100 + traceback: Optional[TracebackType], # noqa: U100 + ) -> None: """ Release the lock. @@ -199,7 +214,7 @@ def __exit__(self, exc_type, exc_value, traceback): # noqa: U100 """ self.release() - def __del__(self): + def __del__(self) -> None: """Called when the lock object is deleted.""" self.release(force=True) diff --git a/src/filelock/_error.py b/src/filelock/_error.py index d4384ff..406a476 100644 --- a/src/filelock/_error.py +++ b/src/filelock/_error.py @@ -1,20 +1,12 @@ -import sys - -if sys.version[0] == 3: - TimeoutError = TimeoutError -else: - TimeoutError = OSError - - class Timeout(TimeoutError): """Raised when the lock could not be acquired in *timeout* seconds.""" - def __init__(self, lock_file): + def __init__(self, lock_file: str) -> None: #: The path of the file lock. self.lock_file = lock_file - def __str__(self): - return "The file lock '{}' could not be acquired.".format(self.lock_file) + def __str__(self) -> str: + return f"The file lock '{self.lock_file}' could not be acquired." __all__ = [ diff --git a/src/filelock/_soft.py b/src/filelock/_soft.py index 83cf2b1..9274374 100644 --- a/src/filelock/_soft.py +++ b/src/filelock/_soft.py @@ -9,7 +9,7 @@ class SoftFileLock(BaseFileLock): """Simply watches the existence of the lock file.""" - def _acquire(self): + def _acquire(self) -> None: raise_on_exist_ro_file(self._lock_file) # first check for exists and read-only mode as the open will mask this case as EEXIST mode = ( @@ -25,13 +25,14 @@ def _acquire(self): pass elif exception.errno == ENOENT: # No such file or directory - parent directory is missing raise - elif exception.errno == EACCES and sys.platform != "win32": # Permission denied - parent dir is R/O + 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 - def _release(self): - os.close(self._lock_file_fd) + def _release(self) -> None: + os.close(self._lock_file_fd) # type: ignore # the lock file is definitely not None self._lock_file_fd = None try: os.remove(self._lock_file) diff --git a/src/filelock/_unix.py b/src/filelock/_unix.py index 4b08c92..19af2de 100644 --- a/src/filelock/_unix.py +++ b/src/filelock/_unix.py @@ -1,37 +1,47 @@ import os +import sys +from abc import ABC +from typing import cast from ._api import BaseFileLock -try: - import fcntl -except ImportError: - fcntl = None - #: a flag to indicate if the fcntl API is available -has_fcntl = fcntl is not None - - -class UnixFileLock(BaseFileLock): - """Uses the :func:`fcntl.flock` to hard lock the lock file on unix systems.""" - - def _acquire(self): - open_mode = os.O_RDWR | os.O_CREAT | os.O_TRUNC - fd = os.open(self._lock_file, open_mode) - try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - except (OSError, IOError): # noqa: B014 # IOError is not OSError on python 2 +has_fcntl = False +if sys.platform == "win32": # pragma: win32 cover + + class UnixFileLock(BaseFileLock, ABC): + """Uses the :func:`fcntl.flock` to hard lock the lock file on unix systems.""" + + +else: # pragma: win32 no cover + try: + import fcntl + except ImportError: + pass + else: + has_fcntl = True + + class UnixFileLock(BaseFileLock): + """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) + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + os.close(fd) + else: + self._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 + fcntl.flock(fd, fcntl.LOCK_UN) os.close(fd) - else: - self._lock_file_fd = fd - - def _release(self): - # 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 = self._lock_file_fd - self._lock_file_fd = None - fcntl.flock(fd, fcntl.LOCK_UN) - os.close(fd) __all__ = [ diff --git a/src/filelock/_util.py b/src/filelock/_util.py index 6d9bfff..05f76f8 100644 --- a/src/filelock/_util.py +++ b/src/filelock/_util.py @@ -1,11 +1,8 @@ import os import stat -import sys -PermissionError = PermissionError if sys.version_info[0] == 3 else OSError - -def raise_on_exist_ro_file(filename): +def raise_on_exist_ro_file(filename: str) -> None: try: file_stat = os.stat(filename) # use stat to do exists + can write to check without race condition except OSError: @@ -13,10 +10,9 @@ def raise_on_exist_ro_file(filename): 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("Permission denied: {!r}".format(filename)) + raise PermissionError(f"Permission denied: {filename!r}") __all__ = [ "raise_on_exist_ro_file", - "PermissionError", ] diff --git a/src/filelock/_windows.py b/src/filelock/_windows.py index 473a379..2bb206c 100644 --- a/src/filelock/_windows.py +++ b/src/filelock/_windows.py @@ -1,49 +1,55 @@ import os +import sys +from abc import ABC from errno import ENOENT +from typing import cast from ._api import BaseFileLock from ._util import raise_on_exist_ro_file -try: +if sys.platform == "win32": # pragma: win32 cover import msvcrt -except ImportError: - msvcrt = None - - -class WindowsFileLock(BaseFileLock): - """Uses the :func:`msvcrt.locking` function to hard lock the lock file on windows systems.""" - - def _acquire(self): - raise_on_exist_ro_file(self._lock_file) - mode = ( - os.O_RDWR # open for read and write - | os.O_CREAT # create file if not exists - | os.O_TRUNC # truncate file if not empty - ) - try: - fd = os.open(self._lock_file, mode) - except OSError as exception: - if exception.errno == ENOENT: # No such file or directory - raise - else: + + class WindowsFileLock(BaseFileLock): + """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 = ( + os.O_RDWR # open for read and write + | os.O_CREAT # create file if not exists + | os.O_TRUNC # truncate file if not empty + ) try: - msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) - except (OSError, IOError): # noqa: B014 # IOError is not OSError on python 2 - os.close(fd) + fd = os.open(self._lock_file, mode) + except OSError as exception: + if exception.errno == ENOENT: # No such file or directory + raise else: - self._lock_file_fd = fd - - def _release(self): - fd = self._lock_file_fd - self._lock_file_fd = None - msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) - os.close(fd) - - try: - os.remove(self._lock_file) - # Probably another instance of the application hat acquired the file lock. - except OSError: - pass + try: + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + except OSError: + os.close(fd) + else: + self._lock_file_fd = fd + + def _release(self) -> None: + fd = cast(int, self._lock_file_fd) + self._lock_file_fd = None + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + os.close(fd) + + try: + os.remove(self._lock_file) + # Probably another instance of the application hat acquired the file lock. + except OSError: + pass + + +else: # pragma: win32 no cover + + class WindowsFileLock(BaseFileLock, ABC): + """Uses the :func:`msvcrt.locking` function to hard lock the lock file on windows systems.""" __all__ = [ diff --git a/src/filelock/py.typed b/src/filelock/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_filelock.py b/tests/test_filelock.py index 9fcde84..138aaec 100644 --- a/tests/test_filelock.py +++ b/tests/test_filelock.py @@ -1,19 +1,20 @@ -from __future__ import unicode_literals - import logging import sys import threading from contextlib import contextmanager +from pathlib import Path from stat import S_IWGRP, S_IWOTH, S_IWUSR +from types import TracebackType +from typing import Callable, Iterator, Optional, Tuple, Type, Union import pytest +from _pytest.logging import LogCaptureFixture -from filelock import FileLock, SoftFileLock, Timeout -from filelock._util import PermissionError +from filelock import BaseFileLock, FileLock, SoftFileLock, Timeout @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) -def test_simple(lock_type, tmp_path, caplog): +def test_simple(lock_type: Type[BaseFileLock], tmp_path: Path, caplog: LogCaptureFixture) -> None: caplog.set_level(logging.DEBUG) lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) @@ -24,17 +25,17 @@ def test_simple(lock_type, tmp_path, caplog): assert not lock.is_locked assert caplog.messages == [ - "Attempting to acquire lock {} on {}".format(id(lock), lock_path), - "Lock {} acquired on {}".format(id(lock), lock_path), - "Attempting to release lock {} on {}".format(id(lock), lock_path), - "Lock {} released on {}".format(id(lock), lock_path), + f"Attempting to acquire lock {id(lock)} on {lock_path}", + f"Lock {id(lock)} acquired on {lock_path}", + f"Attempting to release lock {id(lock)} on {lock_path}", + f"Lock {id(lock)} released on {lock_path}", ] assert [r.levelno for r in caplog.records] == [logging.DEBUG, logging.DEBUG, logging.DEBUG, logging.DEBUG] assert [r.name for r in caplog.records] == ["filelock", "filelock", "filelock", "filelock"] @contextmanager -def make_ro(path): +def make_ro(path: Path) -> Iterator[None]: write = S_IWUSR | S_IWGRP | S_IWOTH path.chmod(path.stat().st_mode & ~write) yield @@ -42,21 +43,21 @@ def make_ro(path): @pytest.fixture() -def tmp_path_ro(tmp_path): +def tmp_path_ro(tmp_path: Path) -> Iterator[Path]: with make_ro(tmp_path): yield tmp_path @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.skipif(sys.platform == "win32", reason="Windows does not have read only folders") -def test_ro_folder(lock_type, tmp_path_ro): +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"): lock.acquire() @pytest.fixture() -def tmp_file_ro(tmp_path): +def tmp_file_ro(tmp_path: Path) -> Iterator[Path]: filename = tmp_path / "a" filename.write_text("") with make_ro(filename): @@ -64,14 +65,14 @@ def tmp_file_ro(tmp_path): @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) -def test_ro_file(lock_type, tmp_file_ro): +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() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) -def test_missing_directory(lock_type, tmp_path_ro): +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)) @@ -80,7 +81,7 @@ def test_missing_directory(lock_type, tmp_path_ro): @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) -def test_nested_context_manager(lock_type, tmp_path): +def test_nested_context_manager(lock_type: Type[BaseFileLock], tmp_path: Path) -> None: # lock is not released before the most outer with statement that locked the lock, is left lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) @@ -103,7 +104,7 @@ def test_nested_context_manager(lock_type, tmp_path): @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) -def test_nested_acquire(lock_type, tmp_path): +def test_nested_acquire(lock_type: Type[BaseFileLock], tmp_path: Path) -> None: # lock is not released before the most outer with statement that locked the lock, is left lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) @@ -126,7 +127,7 @@ def test_nested_acquire(lock_type, tmp_path): @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) -def test_nested_forced_release(lock_type, tmp_path): +def test_nested_forced_release(lock_type: Type[BaseFileLock], tmp_path: Path) -> None: # acquires the lock using a with-statement and releases the lock before leaving the with-statement lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) @@ -142,40 +143,40 @@ def test_nested_forced_release(lock_type, tmp_path): assert not lock.is_locked +_ExcInfoType = Union[Tuple[Type[BaseException], BaseException, TracebackType], Tuple[None, None, None]] + + class ExThread(threading.Thread): - def __init__(self, target, name): - super(ExThread, self).__init__(target=target, name=name) - self.ex = None + def __init__(self, target: Callable[[], None], name: str) -> None: + super().__init__(target=target, name=name) + self.ex: Optional[_ExcInfoType] = None - def run(self): + def run(self) -> None: try: - super(ExThread, self).run() - except Exception: - self.ex = sys.exc_info() + super().run() + except Exception: # pragma: no cover + self.ex = sys.exc_info() # pragma: no cover - def join(self, timeout=None): - super(ExThread, self).join(timeout=timeout) + def join(self, timeout: Optional[float] = None) -> None: + super().join(timeout=timeout) if self.ex is not None: - print("fail from thread {}".format(self.name)) - if sys.version_info[0] == 2: - wrapper_ex = self.ex[1] - raise (wrapper_ex.__class__, wrapper_ex, self.ex[2]) - raise self.ex[0].with_traceback(self.ex[1], self.ex[2]) + print(f"fail from thread {self.name}") # pragma: no cover + raise RuntimeError from self.ex[1] # pragma: no cover @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) -def test_threaded_shared_lock_obj(lock_type, tmp_path): +def test_threaded_shared_lock_obj(lock_type: Type[BaseFileLock], tmp_path: Path) -> None: # Runs 100 threads, which need the filelock. The lock must be acquired if at least one thread required it and # released, as soon as all threads stopped. lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) - def thread_work(): + def thread_work() -> None: for _ in range(100): with lock: assert lock.is_locked - threads = [ExThread(target=thread_work, name="t{}".format(i)) for i in range(100)] + threads = [ExThread(target=thread_work, name=f"t{i}") for i in range(100)] for thread in threads: thread.start() for thread in threads: @@ -185,17 +186,18 @@ def thread_work(): @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) -def test_threaded_lock_different_lock_obj(lock_type, tmp_path): +@pytest.mark.skipif(hasattr(sys, "pypy_version_info") and sys.platform == "win32", reason="deadlocks randomly") +def test_threaded_lock_different_lock_obj(lock_type: Type[BaseFileLock], tmp_path: Path) -> None: # Runs multiple threads, which acquire the same lock file with a different FileLock object. When thread group 1 # acquired the lock, thread group 2 must not hold their lock. - def t_1(): + def t_1() -> None: for _ in range(1000): with lock_1: assert lock_1.is_locked assert not lock_2.is_locked - def t_2(): + def t_2() -> None: for _ in range(1000): with lock_2: assert not lock_1.is_locked @@ -203,7 +205,7 @@ def t_2(): lock_path = tmp_path / "a" lock_1, lock_2 = lock_type(str(lock_path)), lock_type(str(lock_path)) - threads = [(ExThread(t_1, "t1_{}".format(i)), ExThread(t_2, "t2_{}".format(i))) for i in range(10)] + threads = [(ExThread(t_1, f"t1_{i}"), ExThread(t_2, f"t2_{i}")) for i in range(10)] for thread_1, thread_2 in threads: thread_1.start() @@ -217,7 +219,7 @@ def t_2(): @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) -def test_timeout(lock_type, tmp_path): +def test_timeout(lock_type: Type[BaseFileLock], tmp_path: Path) -> None: # raises Timeout error when the lock cannot be acquired lock_path = tmp_path / "a" lock_1, lock_2 = lock_type(str(lock_path)), lock_type(str(lock_path)) @@ -240,7 +242,7 @@ def test_timeout(lock_type, tmp_path): @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) -def test_default_timeout(lock_type, tmp_path): +def test_default_timeout(lock_type: Type[BaseFileLock], tmp_path: Path) -> None: # test if the default timeout parameter works lock_path = tmp_path / "a" lock_1, lock_2 = lock_type(str(lock_path)), lock_type(str(lock_path), timeout=0.1) @@ -272,7 +274,7 @@ def test_default_timeout(lock_type, tmp_path): @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) -def test_context_release_on_exc(lock_type, tmp_path): +def test_context_release_on_exc(lock_type: Type[BaseFileLock], tmp_path: Path) -> None: # lock is released when an exception is thrown in a with-statement lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) @@ -287,7 +289,7 @@ def test_context_release_on_exc(lock_type, tmp_path): @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) -def test_acquire_release_on_exc(lock_type, tmp_path): +def test_acquire_release_on_exc(lock_type: Type[BaseFileLock], tmp_path: Path) -> None: # lock is released when an exception is thrown in a acquire statement lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) @@ -303,7 +305,7 @@ def test_acquire_release_on_exc(lock_type, tmp_path): @pytest.mark.skipif(hasattr(sys, "pypy_version_info"), reason="del() does not trigger GC in PyPy") @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) -def test_del(lock_type, tmp_path): +def test_del(lock_type: Type[BaseFileLock], tmp_path: Path) -> None: # lock is released when the object is deleted lock_path = tmp_path / "a" lock_1, lock_2 = lock_type(str(lock_path)), lock_type(str(lock_path)) @@ -326,7 +328,7 @@ def test_del(lock_type, tmp_path): lock_2.release() -def test_cleanup_soft_lock(tmp_path): +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)) diff --git a/tox.ini b/tox.ini index 7378a70..f851870 100644 --- a/tox.ini +++ b/tox.ini @@ -6,16 +6,14 @@ envlist = py38 py37 py36 - py35 - py27 pypy3 - pypy2 + type coverage docs readme isolated_build = true skip_missing_interpreters = true -minversion = 3.14 +minversion = 3.21 [testenv] description = run tests with {basepython} @@ -23,16 +21,14 @@ passenv = PIP_* PYTEST_* setenv = - COVERAGE_FILE = {toxworkdir}/.coverage.{envname} - {py27,pypy2}: PYTHONWARNINGS = ignore:DEPRECATION::pip._internal.cli.base_command + COVERAGE_FILE = {toxworkdir}{/}.coverage.{envname} extras = testing commands = pytest {tty:--color=yes} {posargs: \ - --junitxml {toxworkdir}{/}junit.{envname}.xml --cov filelock --cov {toxinidir}{/}tests \ + --junitxml {toxworkdir}{/}junit.{envname}.xml --cov {envsitepackagesdir}{/}filelock --cov {toxinidir}{/}tests \ --cov-config=setup.cfg --no-cov-on-fail --cov-report term-missing:skip-covered --cov-context=test \ - --cov-fail-under 80 --cov-report html:{envtmpdir}{/}htmlcov \ - --cov-report xml:{toxworkdir}{/}coverage.{envname}.xml \ + --cov-report html:{envtmpdir}{/}htmlcov --cov-report xml:{toxworkdir}{/}coverage.{envname}.xml \ tests} package = wheel wheel_build_env = .pkg @@ -49,33 +45,40 @@ commands = pre-commit run --all-files --show-diff-on-failure python -c 'import pathlib; print("hint: run \{\} install to add checks as pre-commit hook".format(pathlib.Path(r"{envdir}") / "bin" / "pre-commit"))' +[testenv:type] +description = run type check on code base +setenv = + {tty:MYPY_FORCE_COLOR = 1} +deps = + mypy==0.910 +commands = + mypy --strict src/filelock + mypy --strict tests + [testenv:coverage] -description = [run locally after tests]: combine coverage data and create report; - generates a diff coverage against origin/main (can be changed by setting DIFF_AGAINST env var) +description = combine coverage files and generate diff (against DIFF_AGAINST defaulting to origin/main) passenv = DIFF_AGAINST setenv = COVERAGE_FILE = {toxworkdir}/.coverage skip_install = true deps = - coverage>=5.0.1 + coverage>=5 diff_cover>=3 extras = parallel_show_output = true commands = - python -m coverage combine - python -m coverage report --skip-covered --show-missing - python -m coverage xml -o {toxworkdir}/coverage.xml - python -m coverage html -d {toxworkdir}/htmlcov - python -m diff_cover.diff_cover_tool --compare-branch {env:DIFF_AGAINST:origin/main} {toxworkdir}/coverage.xml + coverage combine + coverage report --skip-covered --show-missing + coverage xml -o {toxworkdir}/coverage.xml + coverage html -d {toxworkdir}/htmlcov + diff-cover --compare-branch {env:DIFF_AGAINST:origin/main} {toxworkdir}/coverage.xml depends = + py310 py39 py38 py37 py36 - py35 - py27 - pypy pypy3 [testenv:docs] @@ -94,7 +97,7 @@ deps = twine>=3 extras = commands = - python -m build -o {envtmpdir} --wheel --sdist . + pyproject-build -o {envtmpdir} --wheel --sdist . twine check {envtmpdir}/* [testenv:dev] diff --git a/whitelist.txt b/whitelist.txt index 1f012ec..26ecf28 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -23,13 +23,16 @@ msvcrt nblck nitpicky param +pathlib pygments rdwr ro +runtime skipif tmp trunc typehints unlck util +win32 wronly