Skip to content

Commit

Permalink
Support reentrant locking on lock file path via optional singleton in…
Browse files Browse the repository at this point in the history
…stance (#283)
  • Loading branch information
nefrob authored Oct 27, 2023
1 parent 16f2a93 commit 3e3455e
Show file tree
Hide file tree
Showing 7 changed files with 129 additions and 26 deletions.
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

0 comments on commit 3e3455e

Please sign in to comment.