diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8742bdae..6b973dcc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,12 +5,12 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.1.1" + rev: "v0.1.3" hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/psf/black - rev: 23.10.0 + rev: 23.10.1 hooks: - id: black - repo: https://github.com/tox-dev/tox-ini-fmt @@ -19,10 +19,10 @@ repos: - id: tox-ini-fmt args: ["-p", "fix"] - repo: https://github.com/tox-dev/pyproject-fmt - rev: "1.2.0" + rev: "1.3.0" hooks: - id: pyproject-fmt - additional_dependencies: ["tox>=4.8"] + additional_dependencies: ["tox>=4.11.3"] - repo: https://github.com/pre-commit/mirrors-prettier rev: "v3.0.3" hooks: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..5fe5136e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,25 @@ +# Contributing + +This page lists the steps needed to set up a development environment and contribute to the project. + +1. Fork and clone this repo. + +2. [Install tox](https://tox.wiki/en/latest/installation.html#via-pipx). + +3. Run tests: + + ```shell + tox run + ``` + + or for a specific python version + + ```shell + tox run -f py311 + ``` + +4. Running other tox commands (eg. linting): + + ```shell + tox -e fix + ``` diff --git a/pyproject.toml b/pyproject.toml index e0bb2c59..ffa154bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,21 +39,21 @@ dynamic = [ "version", ] optional-dependencies.docs = [ - "furo>=2023.7.26", - "sphinx>=7.1.2", + "furo>=2023.9.10", + "sphinx>=7.2.6", "sphinx-autodoc-typehints!=1.23.4,>=1.24", ] optional-dependencies.testing = [ "covdefaults>=2.3", - "coverage>=7.3", - "diff-cover>=7.7", - "pytest>=7.4", + "coverage>=7.3.2", + "diff-cover>=8", + "pytest>=7.4.3", "pytest-cov>=4.1", - "pytest-mock>=3.11.1", - "pytest-timeout>=2.1", + "pytest-mock>=3.12", + "pytest-timeout>=2.2", ] optional-dependencies.typing = [ - 'typing-extensions>=4.7.1; python_version < "3.11"', + 'typing-extensions>=4.8; python_version < "3.11"', ] urls.Documentation = "https://py-filelock.readthedocs.io" urls.Homepage = "https://github.com/tox-dev/py-filelock" diff --git a/src/filelock/__init__.py b/src/filelock/__init__.py index 0b8c1d2f..4cf3b507 100644 --- a/src/filelock/__init__.py +++ b/src/filelock/__init__.py @@ -32,7 +32,7 @@ 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. diff --git a/src/filelock/_api.py b/src/filelock/_api.py index 8a40ccd0..a99d8a0a 100644 --- a/src/filelock/_api.py +++ b/src/filelock/_api.py @@ -8,7 +8,8 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from threading import local -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar +from weakref import WeakValueDictionary from ._error import Timeout @@ -76,25 +77,53 @@ class ThreadLocalFileContext(FileLockContext, local): class BaseFileLock(ABC, contextlib.ContextDecorator): """Abstract base class for a file lock object.""" - def __init__( + _instances: ClassVar[WeakValueDictionary[str, BaseFileLock]] = WeakValueDictionary() + + def __new__( # noqa: PLR0913 + cls, + lock_file: str | os.PathLike[str], + timeout: float = -1, # noqa: ARG003 + mode: int = 0o644, # noqa: ARG003 + thread_local: bool = True, # noqa: ARG003, FBT001, FBT002 + *, + is_singleton: bool = False, + ) -> 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 + + return instance # type: ignore[return-value] # https://github.com/python/mypy/issues/15322 + + def __init__( # noqa: PLR0913 self, lock_file: str | os.PathLike[str], timeout: float = -1, mode: int = 0o644, thread_local: bool = True, # noqa: FBT001, FBT002 + *, + 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 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 # properties of this class. @@ -109,6 +138,11 @@ 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 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""" diff --git a/tests/test_filelock.py b/tests/test_filelock.py index 41cf6e00..a401a183 100644 --- a/tests/test_filelock.py +++ b/tests/test_filelock.py @@ -611,3 +611,47 @@ def test_lock_can_be_non_thread_local( assert lock.lock_counter == 2 lock.release(force=True) + + +@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) +def test_singleton_and_non_singleton_locks_are_distinct(lock_type: type[BaseFileLock], tmp_path: Path) -> None: + lock_path = tmp_path / "a" + lock_1 = lock_type(str(lock_path), is_singleton=False) + assert lock_1.is_singleton is False + + lock_2 = lock_type(str(lock_path), is_singleton=True) + assert lock_2.is_singleton is True + assert lock_2 is not lock_1 + + +@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) +def test_singleton_locks_are_the_same(lock_type: type[BaseFileLock], tmp_path: Path) -> None: + lock_path = tmp_path / "a" + lock_1 = lock_type(str(lock_path), is_singleton=True) + + lock_2 = lock_type(str(lock_path), is_singleton=True) + assert lock_2 is lock_1 + + +@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) +def test_singleton_locks_are_distinct_per_lock_file(lock_type: type[BaseFileLock], tmp_path: Path) -> None: + lock_path_1 = tmp_path / "a" + lock_1 = lock_type(str(lock_path_1), is_singleton=True) + + lock_path_2 = tmp_path / "b" + lock_2 = lock_type(str(lock_path_2), is_singleton=True) + assert lock_1 is not lock_2 + + +@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_singleton_locks_are_deleted_when_no_external_references_exist( + lock_type: type[BaseFileLock], + tmp_path: Path, +) -> None: + lock_path = tmp_path / "a" + lock = lock_type(str(lock_path), is_singleton=True) + + assert lock_type._instances == {str(lock_path): lock} # noqa: SLF001 + del lock + assert lock_type._instances == {} # noqa: SLF001 diff --git a/tox.ini b/tox.ini index 6d00cd3d..9520f1fd 100644 --- a/tox.ini +++ b/tox.ini @@ -38,7 +38,7 @@ description = format the code base to adhere to our styles, and complain about w base_python = python3.10 skip_install = true deps = - pre-commit>=3.3.3 + pre-commit>=3.5 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"))' @@ -46,7 +46,7 @@ commands = [testenv:type] description = run type check on code base deps = - mypy==1.5 + mypy==1.6.1 set_env = {tty:MYPY_FORCE_COLOR = 1} commands = @@ -58,8 +58,8 @@ description = combine coverage files and generate diff (against DIFF_AGAINST def skip_install = true deps = covdefaults>=2.3 - coverage[toml]>=7.3 - diff-cover>=7.7 + coverage[toml]>=7.3.2 + diff-cover>=8 extras = parallel_show_output = true pass_env = @@ -91,7 +91,7 @@ commands = description = check that the long description is valid (need for PyPI) skip_install = true deps = - build[virtualenv]>=0.10 + build[virtualenv]>=1.0.3 twine>=4.0.2 extras = commands =