Skip to content

Commit 192f1ef

Browse files
authored
Use a metaclass to implement the singleton pattern (#340)
* add test to check the number of `__init__` calls * FileLockMeta * fix lint * minor touch * minor touch * revert self._context * fix type check
1 parent 48788c5 commit 192f1ef

File tree

3 files changed

+105
-58
lines changed

3 files changed

+105
-58
lines changed

src/filelock/_api.py

+54-52
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
import os
66
import time
77
import warnings
8-
from abc import ABC, abstractmethod
8+
from abc import ABCMeta, 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, cast
1212
from weakref import WeakValueDictionary
1313

1414
from ._error import Timeout
@@ -77,33 +77,63 @@ class ThreadLocalFileContext(FileLockContext, local):
7777
"""A thread local version of the ``FileLockContext`` class."""
7878

7979

80-
class BaseFileLock(ABC, contextlib.ContextDecorator):
81-
"""Abstract base class for a file lock object."""
82-
83-
_instances: WeakValueDictionary[str, Self]
84-
85-
def __new__( # noqa: PLR0913
80+
class FileLockMeta(ABCMeta):
81+
def __call__( # noqa: PLR0913
8682
cls,
8783
lock_file: str | os.PathLike[str],
88-
timeout: float = -1, # noqa: ARG003
89-
mode: int = 0o644, # noqa: ARG003
90-
thread_local: bool = True, # noqa: FBT001, FBT002, ARG003
84+
timeout: float = -1,
85+
mode: int = 0o644,
86+
thread_local: bool = True, # noqa: FBT001, FBT002
9187
*,
92-
blocking: bool = True, # noqa: ARG003
88+
blocking: bool = True,
9389
is_singleton: bool = False,
94-
**kwargs: Any, # capture remaining kwargs for subclasses # noqa: ARG003, ANN401
95-
) -> Self:
96-
"""Create a new lock object or if specified return the singleton instance for the lock file."""
97-
if not is_singleton:
98-
return super().__new__(cls)
99-
100-
instance = cls._instances.get(str(lock_file))
101-
if not instance:
102-
self = super().__new__(cls)
103-
cls._instances[str(lock_file)] = self
104-
return self
90+
**kwargs: Any, # capture remaining kwargs for subclasses # noqa: ANN401
91+
) -> BaseFileLock:
92+
if is_singleton:
93+
instance = cls._instances.get(str(lock_file)) # type: ignore[attr-defined]
94+
if instance:
95+
params_to_check = {
96+
"thread_local": (thread_local, instance.is_thread_local()),
97+
"timeout": (timeout, instance.timeout),
98+
"mode": (mode, instance.mode),
99+
"blocking": (blocking, instance.blocking),
100+
}
101+
102+
non_matching_params = {
103+
name: (passed_param, set_param)
104+
for name, (passed_param, set_param) in params_to_check.items()
105+
if passed_param != set_param
106+
}
107+
if not non_matching_params:
108+
return cast(BaseFileLock, instance)
109+
110+
# parameters do not match; raise error
111+
msg = "Singleton lock instances cannot be initialized with differing arguments"
112+
msg += "\nNon-matching arguments: "
113+
for param_name, (passed_param, set_param) in non_matching_params.items():
114+
msg += f"\n\t{param_name} (existing lock has {set_param} but {passed_param} was passed)"
115+
raise ValueError(msg)
116+
117+
instance = super().__call__(
118+
lock_file=lock_file,
119+
timeout=timeout,
120+
mode=mode,
121+
thread_local=thread_local,
122+
blocking=blocking,
123+
is_singleton=is_singleton,
124+
**kwargs,
125+
)
126+
127+
if is_singleton:
128+
cls._instances[str(lock_file)] = instance # type: ignore[attr-defined]
129+
130+
return cast(BaseFileLock, instance)
131+
132+
133+
class BaseFileLock(contextlib.ContextDecorator, metaclass=FileLockMeta):
134+
"""Abstract base class for a file lock object."""
105135

106-
return instance # type: ignore[return-value] # https://github.com/python/mypy/issues/15322
136+
_instances: WeakValueDictionary[str, BaseFileLock]
107137

108138
def __init_subclass__(cls, **kwargs: dict[str, Any]) -> None:
109139
"""Setup unique state for lock subclasses."""
@@ -136,34 +166,6 @@ def __init__( # noqa: PLR0913
136166
to pass the same object around.
137167
138168
"""
139-
if is_singleton and hasattr(self, "_context"):
140-
# test whether other parameters match existing instance.
141-
if not self.is_singleton:
142-
msg = "__init__ should only be called on initialized object if it is a singleton"
143-
raise RuntimeError(msg)
144-
145-
params_to_check = {
146-
"thread_local": (thread_local, self.is_thread_local()),
147-
"timeout": (timeout, self.timeout),
148-
"mode": (mode, self.mode),
149-
"blocking": (blocking, self.blocking),
150-
}
151-
152-
non_matching_params = {
153-
name: (passed_param, set_param)
154-
for name, (passed_param, set_param) in params_to_check.items()
155-
if passed_param != set_param
156-
}
157-
if not non_matching_params:
158-
return # bypass initialization because object is already initialized
159-
160-
# parameters do not match; raise error
161-
msg = "Singleton lock instances cannot be initialized with differing arguments"
162-
msg += "\nNon-matching arguments: "
163-
for param_name, (passed_param, set_param) in non_matching_params.items():
164-
msg += f"\n\t{param_name} (existing lock has {set_param} but {passed_param} was passed)"
165-
raise ValueError(msg)
166-
167169
self._is_thread_local = thread_local
168170
self._is_singleton = is_singleton
169171

src/filelock/asyncio.py

+34-6
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
import time
1010
from dataclasses import dataclass
1111
from threading import local
12-
from typing import TYPE_CHECKING, Any, Callable, NoReturn
12+
from typing import TYPE_CHECKING, Any, Callable, NoReturn, cast
1313

14-
from ._api import BaseFileLock, FileLockContext
14+
from ._api import BaseFileLock, FileLockContext, FileLockMeta
1515
from ._error import Timeout
1616
from ._soft import SoftFileLock
1717
from ._unix import UnixFileLock
@@ -67,7 +67,38 @@ async def __aexit__( # noqa: D105
6767
await self.lock.release()
6868

6969

70-
class BaseAsyncFileLock(BaseFileLock):
70+
class AsyncFileLockMeta(FileLockMeta):
71+
def __call__( # type: ignore[override] # noqa: PLR0913
72+
cls, # noqa: N805
73+
lock_file: str | os.PathLike[str],
74+
timeout: float = -1,
75+
mode: int = 0o644,
76+
thread_local: bool = False, # noqa: FBT001, FBT002
77+
*,
78+
blocking: bool = True,
79+
is_singleton: bool = False,
80+
loop: asyncio.AbstractEventLoop | None = None,
81+
run_in_executor: bool = True,
82+
executor: futures.Executor | None = None,
83+
) -> BaseAsyncFileLock:
84+
if thread_local and run_in_executor:
85+
msg = "run_in_executor is not supported when thread_local is True"
86+
raise ValueError(msg)
87+
instance = super().__call__(
88+
lock_file=lock_file,
89+
timeout=timeout,
90+
mode=mode,
91+
thread_local=thread_local,
92+
blocking=blocking,
93+
is_singleton=is_singleton,
94+
loop=loop,
95+
run_in_executor=run_in_executor,
96+
executor=executor,
97+
)
98+
return cast(BaseAsyncFileLock, instance)
99+
100+
101+
class BaseAsyncFileLock(BaseFileLock, metaclass=AsyncFileLockMeta):
71102
"""Base class for asynchronous file locks."""
72103

73104
def __init__( # noqa: PLR0913
@@ -104,9 +135,6 @@ def __init__( # noqa: PLR0913
104135
"""
105136
self._is_thread_local = thread_local
106137
self._is_singleton = is_singleton
107-
if thread_local and run_in_executor:
108-
msg = "run_in_executor is not supported when thread_local is True"
109-
raise ValueError(msg)
110138

111139
# Create the context. Note that external code should not work with the context directly and should instead use
112140
# properties of this class.

tests/test_filelock.py

+17
Original file line numberDiff line numberDiff line change
@@ -785,3 +785,20 @@ class Lock2(lock_type): # type: ignore[valid-type, misc]
785785
assert isinstance(Lock1._instances, WeakValueDictionary) # noqa: SLF001
786786
assert isinstance(Lock2._instances, WeakValueDictionary) # noqa: SLF001
787787
assert Lock1._instances is not Lock2._instances # noqa: SLF001
788+
789+
790+
def test_singleton_locks_when_inheriting_init_is_called_once(tmp_path: Path) -> None:
791+
init_calls = 0
792+
793+
class MyFileLock(FileLock):
794+
def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401
795+
super().__init__(*args, **kwargs)
796+
nonlocal init_calls
797+
init_calls += 1
798+
799+
lock_path = tmp_path / "a"
800+
lock1 = MyFileLock(str(lock_path), is_singleton=True)
801+
lock2 = MyFileLock(str(lock_path), is_singleton=True)
802+
803+
assert lock1 is lock2
804+
assert init_calls == 1

0 commit comments

Comments
 (0)