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

Use a metaclass to implement the singleton pattern #340

Merged
merged 8 commits into from
Jun 19, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 52 additions & 50 deletions src/filelock/_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import os
import time
import warnings
from abc import ABC, abstractmethod
from abc import ABCMeta, abstractmethod
from dataclasses import dataclass
from threading import local
from typing import TYPE_CHECKING, Any
Expand Down Expand Up @@ -77,34 +77,64 @@ class ThreadLocalFileContext(FileLockContext, local):
"""A thread local version of the ``FileLockContext`` class."""


class BaseFileLock(ABC, contextlib.ContextDecorator):
"""Abstract base class for a file lock object."""

_instances: WeakValueDictionary[str, Self]

def __new__( # noqa: PLR0913
class FileLockMeta(ABCMeta):
def __call__( # noqa: PLR0913
cls,
lock_file: str | os.PathLike[str],
timeout: float = -1, # noqa: ARG003
mode: int = 0o644, # noqa: ARG003
thread_local: bool = True, # noqa: FBT001, FBT002, ARG003
timeout: float = -1,
mode: int = 0o644,
thread_local: bool = True, # noqa: FBT001, FBT002
*,
blocking: bool = True, # noqa: ARG003
blocking: bool = True,
is_singleton: bool = False,
**kwargs: Any, # capture remaining kwargs for subclasses # noqa: ARG003, ANN401
) -> 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:
self = super().__new__(cls)
cls._instances[str(lock_file)] = self
return self
**kwargs: Any, # capture remaining kwargs for subclasses # noqa: ANN401
) -> BaseFileLock:
if is_singleton:
instance = cls._instances.get(str(lock_file))
if instance:
params_to_check = {
"thread_local": (thread_local, instance.is_thread_local()),
"timeout": (timeout, instance.timeout),
"mode": (mode, instance.mode),
"blocking": (blocking, instance.blocking),
}

non_matching_params = {
name: (passed_param, set_param)
for name, (passed_param, set_param) in params_to_check.items()
if passed_param != set_param
}
if not non_matching_params:
return instance

# parameters do not match; raise error
msg = "Singleton lock instances cannot be initialized with differing arguments"
msg += "\nNon-matching arguments: "
for param_name, (passed_param, set_param) in non_matching_params.items():
msg += f"\n\t{param_name} (existing lock has {set_param} but {passed_param} was passed)"
raise ValueError(msg)

instance = super().__call__(
lock_file=lock_file,
timeout=timeout,
mode=mode,
thread_local=thread_local,
blocking=blocking,
is_singleton=is_singleton,
**kwargs,
)

if is_singleton:
cls._instances[str(lock_file)] = instance

return instance # type: ignore[return-value] # https://github.com/python/mypy/issues/15322


class BaseFileLock(contextlib.ContextDecorator, metaclass=FileLockMeta):
"""Abstract base class for a file lock object."""

_instances: WeakValueDictionary[str, Self]

def __init_subclass__(cls, **kwargs: dict[str, Any]) -> None:
"""Setup unique state for lock subclasses."""
super().__init_subclass__(**kwargs)
Expand Down Expand Up @@ -136,34 +166,6 @@ def __init__( # noqa: PLR0913
to pass the same object around.

"""
if is_singleton and hasattr(self, "_context"):
# test whether other parameters match existing instance.
if not self.is_singleton:
msg = "__init__ should only be called on initialized object if it is a singleton"
raise RuntimeError(msg)

params_to_check = {
"thread_local": (thread_local, self.is_thread_local()),
"timeout": (timeout, self.timeout),
"mode": (mode, self.mode),
"blocking": (blocking, self.blocking),
}

non_matching_params = {
name: (passed_param, set_param)
for name, (passed_param, set_param) in params_to_check.items()
if passed_param != set_param
}
if not non_matching_params:
return # bypass initialization because object is already initialized

# parameters do not match; raise error
msg = "Singleton lock instances cannot be initialized with differing arguments"
msg += "\nNon-matching arguments: "
for param_name, (passed_param, set_param) in non_matching_params.items():
msg += f"\n\t{param_name} (existing lock has {set_param} but {passed_param} was passed)"
raise ValueError(msg)

self._is_thread_local = thread_local
self._is_singleton = is_singleton

Expand Down
37 changes: 32 additions & 5 deletions src/filelock/asyncio.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from threading import local
from typing import TYPE_CHECKING, Any, Callable, NoReturn

from ._api import BaseFileLock, FileLockContext
from ._api import BaseFileLock, FileLockContext, FileLockMeta
from ._error import Timeout
from ._soft import SoftFileLock
from ._unix import UnixFileLock
Expand Down Expand Up @@ -67,7 +67,37 @@ async def __aexit__( # noqa: D105
await self.lock.release()


class BaseAsyncFileLock(BaseFileLock):
class AsyncFileLockMeta(FileLockMeta):
def __call__( # noqa: PLR0913
cls, # noqa: N805
lock_file: str | os.PathLike[str],
timeout: float = -1,
mode: int = 0o644,
thread_local: bool = False, # noqa: FBT001, FBT002
*,
blocking: bool = True,
is_singleton: bool = False,
loop: asyncio.AbstractEventLoop | None = None,
run_in_executor: bool = True,
executor: futures.Executor | None = None,
) -> BaseAsyncFileLock:
if thread_local and run_in_executor:
msg = "run_in_executor is not supported when thread_local is True"
raise ValueError(msg)
return super().__call__(
lock_file=lock_file,
timeout=timeout,
mode=mode,
thread_local=thread_local,
blocking=blocking,
is_singleton=is_singleton,
loop=loop,
run_in_executor=run_in_executor,
executor=executor,
)


class BaseAsyncFileLock(BaseFileLock, metaclass=AsyncFileLockMeta):
"""Base class for asynchronous file locks."""

def __init__( # noqa: PLR0913
Expand Down Expand Up @@ -104,9 +134,6 @@ def __init__( # noqa: PLR0913
"""
self._is_thread_local = thread_local
self._is_singleton = is_singleton
if thread_local and run_in_executor:
msg = "run_in_executor is not supported when thread_local is True"
raise ValueError(msg)

# Create the context. Note that external code should not work with the context directly and should instead use
# properties of this class.
Expand Down
17 changes: 17 additions & 0 deletions tests/test_filelock.py
Original file line number Diff line number Diff line change
Expand Up @@ -785,3 +785,20 @@ class Lock2(lock_type): # type: ignore[valid-type, misc]
assert isinstance(Lock1._instances, WeakValueDictionary) # noqa: SLF001
assert isinstance(Lock2._instances, WeakValueDictionary) # noqa: SLF001
assert Lock1._instances is not Lock2._instances # noqa: SLF001


def test_singleton_locks_when_inheriting_init_is_called_once(tmp_path: Path) -> None:
init_calls = 0

class MyFileLock(FileLock):
def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401
super().__init__(*args, **kwargs)
nonlocal init_calls
init_calls += 1

lock_path = tmp_path / "a"
lock1 = MyFileLock(str(lock_path), is_singleton=True)
lock2 = MyFileLock(str(lock_path), is_singleton=True)

assert lock1 is lock2
assert init_calls == 1
Loading