Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support reentrant locking on lock file path via optional singleton instance #283

Merged
merged 12 commits into from
Oct 27, 2023
8 changes: 4 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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
```
16 changes: 8 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/filelock/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 42 additions & 8 deletions src/filelock/_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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"""
Expand Down
44 changes: 44 additions & 0 deletions tests/test_filelock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 5 additions & 5 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@ 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"))'

[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 =
Expand All @@ -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 =
Expand Down Expand Up @@ -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 =
Expand Down