Coverage for python / lsst / utils / classes.py: 60%
41 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-26 08:43 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-26 08:43 +0000
1# This file is part of utils.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
11#
13"""Utilities to help with class creation."""
15from __future__ import annotations
17__all__ = ["Singleton", "cached_getter", "immutable"]
19import functools
20from collections.abc import Callable
21from threading import RLock
22from typing import Any, ClassVar, TypeVar
25class Singleton(type):
26 """Metaclass to convert a class to a Singleton.
28 If this metaclass is used the constructor for the singleton class must
29 take no arguments. This is because a singleton class will only accept
30 the arguments the first time an instance is instantiated.
31 Therefore since you do not know if the constructor has been called yet it
32 is safer to always call it with no arguments and then call a method to
33 adjust state of the singleton.
34 """
36 _instances: ClassVar[dict[type, Any]] = {}
37 # This lock isn't ideal because it is shared between all classes using this
38 # metaclass, but current use cases don't do long-running I/O during
39 # initialization so the performance impact should be low. It must be an
40 # RLock instead of a regular Lock because one singleton class might try to
41 # instantiate another as part of its initialization.
42 __lock: ClassVar[RLock] = RLock()
44 # Signature is intentionally not substitutable for type.__call__ (no *args,
45 # **kwargs) to require classes that use this metaclass to have no
46 # constructor arguments.
47 def __call__(cls) -> Any:
48 with cls.__lock:
49 if cls not in cls._instances:
50 cls._instances[cls] = super().__call__()
51 return cls._instances[cls]
54_T = TypeVar("_T", bound=Any)
57def immutable(cls: _T) -> _T:
58 """Decorate a class to simulate a simple form of immutability.
60 A class decorated as `immutable` may only set each of its attributes once;
61 any attempts to set an already-set attribute will raise `AttributeError`.
63 Notes
64 -----
65 Subclasses of classes marked with ``@immutable`` are also immutable.
67 Because this behavior interferes with the default implementation for the
68 ``pickle`` modules, `immutable` provides implementations of
69 ``__getstate__`` and ``__setstate__`` that override this behavior.
70 Immutable classes can then implement pickle via ``__reduce__`` or
71 ``__getnewargs__``.
73 Following the example of Python's built-in immutable types, such as `str`
74 and `tuple`, the `immutable` decorator provides a ``__copy__``
75 implementation that just returns ``self``, because there is no reason to
76 actually copy an object if none of its shared owners can modify it.
78 Similarly, objects that are recursively (i.e. are themselves immutable and
79 have only recursively immutable attributes) should also reimplement
80 ``__deepcopy__`` to return ``self``. This is not done by the decorator, as
81 it has no way of checking for recursive immutability.
82 """
84 def __setattr__(self: _T, name: str, value: Any) -> None: # noqa: N807
85 if hasattr(self, name):
86 raise AttributeError(f"{cls.__name__} instances are immutable.")
87 object.__setattr__(self, name, value)
89 # mypy says the variable here has signature (str, Any) i.e. no "self";
90 # I think it's just confused by descriptor stuff.
91 cls.__setattr__ = __setattr__
93 def __getstate__(self: _T) -> dict: # noqa: N807
94 # Disable default state-setting when unpickled.
95 return {}
97 cls.__getstate__ = __getstate__
99 def __setstate__(self: _T, state: Any) -> None: # noqa: N807
100 # Disable default state-setting when copied.
101 # Sadly what works for pickle doesn't work for copy.
102 assert not state
104 cls.__setstate__ = __setstate__
106 def __copy__(self: _T) -> _T: # noqa: N807
107 return self
109 cls.__copy__ = __copy__
110 return cls
113_S = TypeVar("_S")
114_R = TypeVar("_R")
117def cached_getter(func: Callable[[_S], _R]) -> Callable[[_S], _R]:
118 """Decorate a method to cache the result.
120 Only works on methods that take only ``self``
121 as an argument, and returns the cached result on subsequent calls.
123 Parameters
124 ----------
125 func : `~collections.abc.Callable`
126 Method from which the result should be cached.
128 Returns
129 -------
130 `~collections.abc.Callable`
131 Decorated method.
133 Notes
134 -----
135 This is intended primarily as a stopgap for Python 3.8's more sophisticated
136 ``functools.cached_property``, but it is also explicitly compatible with
137 the `immutable` decorator, which may not be true of ``cached_property``.
139 `cached_getter` guarantees that the cached value will be stored in
140 an attribute named ``_cached_{name-of-decorated-function}``. Classes that
141 use `cached_getter` are responsible for guaranteeing that this name is not
142 otherwise used, and is included if ``__slots__`` is defined.
143 """
144 attribute = f"_cached_{func.__name__}"
146 @functools.wraps(func)
147 def inner(self: _S) -> _R:
148 if not hasattr(self, attribute):
149 object.__setattr__(self, attribute, func(self))
150 return getattr(self, attribute)
152 return inner