Skip to content

Commit 3e3455e

Browse files
authored
Support reentrant locking on lock file path via optional singleton instance (#283)
1 parent 16f2a93 commit 3e3455e

File tree

7 files changed

+129
-26
lines changed

7 files changed

+129
-26
lines changed

.pre-commit-config.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ repos:
55
- id: end-of-file-fixer
66
- id: trailing-whitespace
77
- repo: https://github.com/astral-sh/ruff-pre-commit
8-
rev: "v0.1.1"
8+
rev: "v0.1.3"
99
hooks:
1010
- id: ruff
1111
args: [--fix, --exit-non-zero-on-fix]
1212
- repo: https://github.com/psf/black
13-
rev: 23.10.0
13+
rev: 23.10.1
1414
hooks:
1515
- id: black
1616
- repo: https://github.com/tox-dev/tox-ini-fmt
@@ -19,10 +19,10 @@ repos:
1919
- id: tox-ini-fmt
2020
args: ["-p", "fix"]
2121
- repo: https://github.com/tox-dev/pyproject-fmt
22-
rev: "1.2.0"
22+
rev: "1.3.0"
2323
hooks:
2424
- id: pyproject-fmt
25-
additional_dependencies: ["tox>=4.8"]
25+
additional_dependencies: ["tox>=4.11.3"]
2626
- repo: https://github.com/pre-commit/mirrors-prettier
2727
rev: "v3.0.3"
2828
hooks:

CONTRIBUTING.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Contributing
2+
3+
This page lists the steps needed to set up a development environment and contribute to the project.
4+
5+
1. Fork and clone this repo.
6+
7+
2. [Install tox](https://tox.wiki/en/latest/installation.html#via-pipx).
8+
9+
3. Run tests:
10+
11+
```shell
12+
tox run
13+
```
14+
15+
or for a specific python version
16+
17+
```shell
18+
tox run -f py311
19+
```
20+
21+
4. Running other tox commands (eg. linting):
22+
23+
```shell
24+
tox -e fix
25+
```

pyproject.toml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,21 +39,21 @@ dynamic = [
3939
"version",
4040
]
4141
optional-dependencies.docs = [
42-
"furo>=2023.7.26",
43-
"sphinx>=7.1.2",
42+
"furo>=2023.9.10",
43+
"sphinx>=7.2.6",
4444
"sphinx-autodoc-typehints!=1.23.4,>=1.24",
4545
]
4646
optional-dependencies.testing = [
4747
"covdefaults>=2.3",
48-
"coverage>=7.3",
49-
"diff-cover>=7.7",
50-
"pytest>=7.4",
48+
"coverage>=7.3.2",
49+
"diff-cover>=8",
50+
"pytest>=7.4.3",
5151
"pytest-cov>=4.1",
52-
"pytest-mock>=3.11.1",
53-
"pytest-timeout>=2.1",
52+
"pytest-mock>=3.12",
53+
"pytest-timeout>=2.2",
5454
]
5555
optional-dependencies.typing = [
56-
'typing-extensions>=4.7.1; python_version < "3.11"',
56+
'typing-extensions>=4.8; python_version < "3.11"',
5757
]
5858
urls.Documentation = "https://py-filelock.readthedocs.io"
5959
urls.Homepage = "https://github.com/tox-dev/py-filelock"

src/filelock/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
if warnings is not None:
3333
warnings.warn("only soft file lock is available", stacklevel=2)
3434

35-
if TYPE_CHECKING: # noqa: SIM108
35+
if TYPE_CHECKING:
3636
FileLock = SoftFileLock
3737
else:
3838
#: Alias for the lock, which should be used for the current platform.

src/filelock/_api.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
from abc import ABC, abstractmethod
99
from dataclasses import dataclass
1010
from threading import local
11-
from typing import TYPE_CHECKING, Any
11+
from typing import TYPE_CHECKING, Any, ClassVar
12+
from weakref import WeakValueDictionary
1213

1314
from ._error import Timeout
1415

@@ -76,25 +77,53 @@ class ThreadLocalFileContext(FileLockContext, local):
7677
class BaseFileLock(ABC, contextlib.ContextDecorator):
7778
"""Abstract base class for a file lock object."""
7879

79-
def __init__(
80+
_instances: ClassVar[WeakValueDictionary[str, BaseFileLock]] = WeakValueDictionary()
81+
82+
def __new__( # noqa: PLR0913
83+
cls,
84+
lock_file: str | os.PathLike[str],
85+
timeout: float = -1, # noqa: ARG003
86+
mode: int = 0o644, # noqa: ARG003
87+
thread_local: bool = True, # noqa: ARG003, FBT001, FBT002
88+
*,
89+
is_singleton: bool = False,
90+
) -> Self:
91+
"""Create a new lock object or if specified return the singleton instance for the lock file."""
92+
if not is_singleton:
93+
return super().__new__(cls)
94+
95+
instance = cls._instances.get(str(lock_file))
96+
if not instance:
97+
instance = super().__new__(cls)
98+
cls._instances[str(lock_file)] = instance
99+
100+
return instance # type: ignore[return-value] # https://github.com/python/mypy/issues/15322
101+
102+
def __init__( # noqa: PLR0913
80103
self,
81104
lock_file: str | os.PathLike[str],
82105
timeout: float = -1,
83106
mode: int = 0o644,
84107
thread_local: bool = True, # noqa: FBT001, FBT002
108+
*,
109+
is_singleton: bool = False,
85110
) -> None:
86111
"""
87112
Create a new lock object.
88113
89114
:param lock_file: path to the file
90-
:param timeout: default timeout when acquiring the lock, in seconds. It will be used as fallback value in
91-
the acquire method, if no timeout value (``None``) is given. If you want to disable the timeout, set it
92-
to a negative value. A timeout of 0 means, that there is exactly one attempt to acquire the file lock.
93-
:param mode: file permissions for the lockfile.
94-
:param thread_local: Whether this object's internal context should be thread local or not.
95-
If this is set to ``False`` then the lock will be reentrant across threads.
115+
:param timeout: default timeout when acquiring the lock, in seconds. It will be used as fallback value in \
116+
the acquire method, if no timeout value (``None``) is given. If you want to disable the timeout, set it \
117+
to a negative value. A timeout of 0 means, that there is exactly one attempt to acquire the file lock.
118+
:param mode: file permissions for the lockfile
119+
:param thread_local: Whether this object's internal context should be thread local or not. If this is set to \
120+
``False`` then the lock will be reentrant across threads.
121+
:param is_singleton: If this is set to ``True`` then only one instance of this class will be created \
122+
per lock file. This is useful if you want to use the lock object for reentrant locking without needing \
123+
to pass the same object around.
96124
"""
97125
self._is_thread_local = thread_local
126+
self._is_singleton = is_singleton
98127

99128
# Create the context. Note that external code should not work with the context directly and should instead use
100129
# properties of this class.
@@ -109,6 +138,11 @@ def is_thread_local(self) -> bool:
109138
""":return: a flag indicating if this lock is thread local or not"""
110139
return self._is_thread_local
111140

141+
@property
142+
def is_singleton(self) -> bool:
143+
""":return: a flag indicating if this lock is singleton or not"""
144+
return self._is_singleton
145+
112146
@property
113147
def lock_file(self) -> str:
114148
""":return: path to the lock file"""

tests/test_filelock.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,3 +611,47 @@ def test_lock_can_be_non_thread_local(
611611
assert lock.lock_counter == 2
612612

613613
lock.release(force=True)
614+
615+
616+
@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock])
617+
def test_singleton_and_non_singleton_locks_are_distinct(lock_type: type[BaseFileLock], tmp_path: Path) -> None:
618+
lock_path = tmp_path / "a"
619+
lock_1 = lock_type(str(lock_path), is_singleton=False)
620+
assert lock_1.is_singleton is False
621+
622+
lock_2 = lock_type(str(lock_path), is_singleton=True)
623+
assert lock_2.is_singleton is True
624+
assert lock_2 is not lock_1
625+
626+
627+
@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock])
628+
def test_singleton_locks_are_the_same(lock_type: type[BaseFileLock], tmp_path: Path) -> None:
629+
lock_path = tmp_path / "a"
630+
lock_1 = lock_type(str(lock_path), is_singleton=True)
631+
632+
lock_2 = lock_type(str(lock_path), is_singleton=True)
633+
assert lock_2 is lock_1
634+
635+
636+
@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock])
637+
def test_singleton_locks_are_distinct_per_lock_file(lock_type: type[BaseFileLock], tmp_path: Path) -> None:
638+
lock_path_1 = tmp_path / "a"
639+
lock_1 = lock_type(str(lock_path_1), is_singleton=True)
640+
641+
lock_path_2 = tmp_path / "b"
642+
lock_2 = lock_type(str(lock_path_2), is_singleton=True)
643+
assert lock_1 is not lock_2
644+
645+
646+
@pytest.mark.skipif(hasattr(sys, "pypy_version_info"), reason="del() does not trigger GC in PyPy")
647+
@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock])
648+
def test_singleton_locks_are_deleted_when_no_external_references_exist(
649+
lock_type: type[BaseFileLock],
650+
tmp_path: Path,
651+
) -> None:
652+
lock_path = tmp_path / "a"
653+
lock = lock_type(str(lock_path), is_singleton=True)
654+
655+
assert lock_type._instances == {str(lock_path): lock} # noqa: SLF001
656+
del lock
657+
assert lock_type._instances == {} # noqa: SLF001

tox.ini

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,15 @@ description = format the code base to adhere to our styles, and complain about w
3838
base_python = python3.10
3939
skip_install = true
4040
deps =
41-
pre-commit>=3.3.3
41+
pre-commit>=3.5
4242
commands =
4343
pre-commit run --all-files --show-diff-on-failure
4444
python -c 'import pathlib; print("hint: run \{\} install to add checks as pre-commit hook".format(pathlib.Path(r"{envdir}") / "bin" / "pre-commit"))'
4545

4646
[testenv:type]
4747
description = run type check on code base
4848
deps =
49-
mypy==1.5
49+
mypy==1.6.1
5050
set_env =
5151
{tty:MYPY_FORCE_COLOR = 1}
5252
commands =
@@ -58,8 +58,8 @@ description = combine coverage files and generate diff (against DIFF_AGAINST def
5858
skip_install = true
5959
deps =
6060
covdefaults>=2.3
61-
coverage[toml]>=7.3
62-
diff-cover>=7.7
61+
coverage[toml]>=7.3.2
62+
diff-cover>=8
6363
extras =
6464
parallel_show_output = true
6565
pass_env =
@@ -91,7 +91,7 @@ commands =
9191
description = check that the long description is valid (need for PyPI)
9292
skip_install = true
9393
deps =
94-
build[virtualenv]>=0.10
94+
build[virtualenv]>=1.0.3
9595
twine>=4.0.2
9696
extras =
9797
commands =

0 commit comments

Comments
 (0)