• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

LSSTDESC / CCL / 5191066774

06 Jun 2023 04:42PM UTC coverage: 97.543% (+0.4%) from 97.136%
5191066774

Pull #1087

github

web-flow
Merge 987bfdbae into 17a0e5a2a
Pull Request #1087: v3 preparation

83 of 83 new or added lines in 36 files covered. (100.0%)

5122 of 5251 relevant lines covered (97.54%)

0.98 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.95
/pyccl/_core/schema.py
1
__all__ = ("ObjectLock",
1✔
2
           "UnlockInstance", "unlock_instance",
3
           "CustomRepr", "CustomEq",
4
           "CCLObject", "CCLAutoRepr", "CCLNamedClass",)
5

6
import functools
1✔
7
from abc import ABC, abstractmethod
1✔
8
from inspect import signature
1✔
9
from _thread import RLock
1✔
10

11
import numpy as np
1✔
12

13

14
class ObjectLock:
1✔
15
    """Control the lock state (immutability) of a ``CCLObject``."""
1✔
16
    _locked: bool = False
1✔
17
    _lock_id: int = None
1✔
18

19
    def __repr__(self):
1✔
20
        return f"{self.__class__.__name__}(locked={self.locked})"
1✔
21

22
    @property
1✔
23
    def locked(self):
1✔
24
        """Check if the object is locked."""
25
        return self._locked
1✔
26

27
    @property
1✔
28
    def active(self):
1✔
29
        """Check if an unlocking context manager is active."""
30
        return self._lock_id is not None
1✔
31

32
    def lock(self):
1✔
33
        """Lock the object."""
34
        self._locked = True
1✔
35
        self._lock_id = None
1✔
36

37
    def unlock(self, manager_id=None):
1✔
38
        """Unlock the object."""
39
        self._locked = False
1✔
40
        if manager_id is not None:
1✔
41
            self._lock_id = manager_id
1✔
42

43

44
class UnlockInstance:
1✔
45
    """Context manager that temporarily unlocks an immutable instance
1✔
46
    of ``CCLObject``.
47

48
    Parameters:
49
        instance (``CCLObject``):
50
            Instance of ``CCLObject`` to unlock within the scope
51
            of the context manager.
52
        mutate (``bool``):
53
            If the enclosed function mutates the object, the stored
54
            representation is automatically deleted.
55
    """
56

57
    def __init__(self, instance, *, mutate=True):
1✔
58
        self.instance = instance
1✔
59
        self.mutate = mutate
1✔
60
        # Define these attributes for easy access.
61
        self.id = id(self)
1✔
62
        self.thread_lock = RLock()
1✔
63
        # We want to catch and exit if the instance is not a CCLObject.
64
        # Hopefully this will be caught downstream.
65
        self.check_instance = isinstance(instance, CCLObject)
1✔
66
        if self.check_instance:
1✔
67
            self.object_lock = instance._object_lock
1✔
68

69
    def __enter__(self):
1✔
70
        if not self.check_instance:
1✔
71
            return
1✔
72

73
        with self.thread_lock:
1✔
74
            # Prevent simultaneous enclosing of a single instance.
75
            if self.object_lock.active:
1✔
76
                # Context manager already active.
77
                return
1✔
78

79
            # Unlock and store the fingerprint of this context manager so that
80
            # only this context manager is allowed to run on the instance.
81
            self.object_lock.unlock(manager_id=self.id)
1✔
82

83
    def __exit__(self, type, value, traceback):
1✔
84
        if not self.check_instance:
1✔
85
            return
1✔
86

87
        # If another context manager is running,
88
        # do nothing; otherwise reset.
89
        if self.id != self.object_lock._lock_id:
1✔
90
            return
1✔
91

92
        # Lock the instance on exit.
93
        # self.object_lock.lock()  # TODO: Uncomment for CCLv3.
94

95
    @classmethod
1✔
96
    def unlock_instance(cls, func=None, *, name=None, mutate=True):
1✔
97
        """Decorator that temporarily unlocks an instance of CCLObject.
98

99
        Arguments:
100
            func (``function``):
101
                Function which changes one of its ``CCLObject`` arguments.
102
            name (``str``):
103
                Name of the parameter to unlock. Defaults to the first one.
104
                If not a ``CCLObject`` the decorator will do nothing.
105
            mutate (``bool``):
106
                If after the function ``instance_old != instance_new``, the
107
                instance is mutated. If ``True``, the representation of the
108
                object will be reset.
109
        """
110
        if func is None:
1✔
111
            # called with parentheses
112
            return functools.partial(cls.unlock_instance, name=name,
1✔
113
                                     mutate=mutate)
114

115
        if not hasattr(func, "__signature__"):
1✔
116
            # store the function signature
117
            func.__signature__ = signature(func)
1✔
118
        names = list(func.__signature__.parameters.keys())
1✔
119
        name = names[0] if name is None else name  # default name
1✔
120
        if name not in names:
1✔
121
            # ensure the name makes sense
122
            raise NameError(f"{name} does not exist in {func.__name__}.")
1✔
123

124
        @functools.wraps(func)
1✔
125
        def wrapper(*args, **kwargs):
1✔
126
            bound = func.__signature__.bind(*args, **kwargs)
1✔
127
            with UnlockInstance(bound.arguments[name], mutate=mutate):
1✔
128
                return func(*args, **kwargs)
1✔
129
        return wrapper
1✔
130

131
    @classmethod
1✔
132
    def Funlock(cls, cl, name, mutate: bool):
1✔
133
        """Allow an instance to change or mutate when `name` is called."""
134
        func = vars(cl).get(name)
×
135
        if func is not None:
×
136
            newfunc = cls.unlock_instance(mutate=mutate)(func)
×
137
            setattr(cl, name, newfunc)
×
138

139

140
unlock_instance = UnlockInstance.unlock_instance
1✔
141

142

143
def is_equal(this, other):
1✔
144
    """Powerful helper for equivalence checking."""
145
    try:
1✔
146
        np.testing.assert_equal(this, other)
1✔
147
        return True
1✔
148
    except AssertionError:
1✔
149
        return False
1✔
150

151

152
class _CustomMethod:
1✔
153
    """Subclasses specifying a method string control whether the custom
1✔
154
    method is used in subclasses of ``CCLObject``.
155
    """
156

157
    def __init_subclass__(cls, *, method):
1✔
158
        super().__init_subclass__()
1✔
159
        if method not in vars(cls):
1✔
160
            raise ValueError(
1✔
161
                f"Subclass must contain a default {method} implementation.")
162
        cls._method = method
1✔
163
        cls._enabled: bool = True
1✔
164
        cls._classes: dict = {}
1✔
165

166
    @classmethod
1✔
167
    def register(cls, cl):
1✔
168
        """Register class to the dictionary of classes with custom methods."""
169
        if cls._method in vars(cls):
1✔
170
            cls._classes[cl] = getattr(cl, cls._method)
1✔
171

172
    @classmethod
1✔
173
    def enable(cls):
1✔
174
        """Enable the custom methods if they exist."""
175
        for cl, method in cls._classes.items():
1✔
176
            setattr(cl, cls._method, method)
1✔
177
        cls._enabled = True
1✔
178

179
    @classmethod
1✔
180
    def disable(cls):
1✔
181
        """Disable custom methods and fall back to Python defaults."""
182
        for cl in cls._classes.keys():
1✔
183
            default = getattr(cls, cls._method)
1✔
184
            setattr(cl, cls._method, default)
1✔
185
        cls._enabled = False
1✔
186

187

188
class CustomEq(_CustomMethod, method="__eq__"):
1✔
189
    """Controls the usage of custom ``__eq__`` for ``CCLObjects``."""
1✔
190

191
    def __eq__(self, other):
1✔
192
        # Default `eq`.
193
        return self is other
1✔
194

195

196
class CustomRepr(_CustomMethod, method="__repr__"):
1✔
197
    """Controls the usage of custom ``__repr__`` for ``CCLObjects``."""
1✔
198

199
    def __repr__(self):
1✔
200
        # Default `repr`.
201
        return object.__repr__(self)
1✔
202

203

204
class CCLObject(ABC):
1✔
205
    """Base for CCL objects.
1✔
206

207
    All CCL objects inherit ``__eq__`` and ``__hash__`` methods from here.
208
    Both methods rely on ``__repr__`` uniqueness. This aims to homogenize
209
    equivalence checking, and to standardize the use of hash.
210

211
    Overview
212
    --------
213
    ``CCLObjects`` inherit ``__hash__``, which consistently hashes the
214
    representation string. They also inherit ``__eq__`` which checks for
215
    representation equivalence.
216

217
    In the implemented scheme, each ``CCLObject`` may have its own, specialized
218
    ``__repr__`` method overloaded. Object representations have to be unique
219
    for equivalent objects. If no ``__repr__`` is provided, the default from
220
    ``object`` is used.
221

222
    Mutation
223
    --------
224
    ``CCLObjects`` are by default immutable. This aims to provide a failsafe
225
    mechanism, where, changing attributes has to trigger a re-computation
226
    of something else inside of the instance, rather than simply doing a value
227
    change.
228

229
    This immutability mechanism can be safely bypassed if a subclass defines an
230
    ``update_parameters`` method. ``CCLObjects`` temporarily unlock whenever
231
    this method is called.
232

233
    Internal State vs. Mutation
234
    ---------------------------
235
    Other methods that use ``setattr`` can only do that if they are decorated
236
    with ``@unlock_instance`` or if the particular code block that makes the
237
    change is enclosed within the ``UnlockInstance`` context manager.
238
    If neither is provided, an exception is raised.
239

240
    If such methods only change the instance's internal state, the decorator
241
    may be called with ``@unlock_instance(mutate=False)`` (or equivalently
242
    for the context manager ``UnlockInstance(..., mutate=False)``). Otherwise,
243
    the instance is assumed to have mutated.
244
    """
245

246
    def __init_subclass__(cls, **kwargs):
1✔
247
        super().__init_subclass__(**kwargs)
1✔
248

249
        # 1. Store the initialization signature on import.
250
        cls.__signature__ = signature(cls.__init__)
1✔
251

252
        # 2. Register subclasses with custom dunder method implementations.
253
        CustomEq.register(cls)
1✔
254
        CustomRepr.register(cls)
1✔
255

256
        # 3. Unlock instance on specific methods.  # TODO: Uncomment for CCLv3.
257
        # UnlockInstance.Funlock(cls, "__init__", mutate=False)
258
        # UnlockInstance.Funlock(cls, "update_parameters", mutate=True)
259

260
    def __new__(cls, *args, **kwargs):
1✔
261
        # Populate every instance with an `ObjectLock` as attribute.
262
        instance = super().__new__(cls)
1✔
263
        object.__setattr__(instance, "_object_lock", ObjectLock())
1✔
264
        return instance
1✔
265

266
    def __setattr__(self, name, value):
1✔
267
        if self._object_lock.locked:
1✔
268
            raise AttributeError("CCL objects can only be updated via "
×
269
                                 "`update_parameters`, if implemented.")
270
        object.__setattr__(self, name, value)
1✔
271

272
    def update_parameters(self, **kwargs):
1✔
273
        name = self.__class__.__name__
1✔
274
        raise NotImplementedError(f"{name} objects are immutable.")
1✔
275

276
    def __repr__(self):
1✔
277
        # By default we use `__repr__` from `object`.
278
        return object.__repr__(self)
1✔
279

280
    def __hash__(self):
1✔
281
        # `__hash__` makes use of the `repr` of the object,
282
        # so we have to make sure that the `repr` is unique.
283
        return hash(repr(self))
1✔
284

285
    def __eq__(self, other):
1✔
286
        # Exit early if it is the same object.
287
        if self is other:
1✔
288
            return True
1✔
289
        # Two same-type objects are equal if their representations are equal.
290
        if type(self) is not type(other):
1✔
291
            return False
1✔
292
        # Compare the attributes listed in `__eq_attrs__`.
293
        if hasattr(self, "__eq_attrs__"):
1✔
294
            for attr in self.__eq_attrs__:
1✔
295
                if not is_equal(getattr(self, attr), getattr(other, attr)):
1✔
296
                    return False
1✔
297
            return True
1✔
298
        return False
1✔
299

300

301
class CCLAutoRepr(CCLObject):
1✔
302
    """Base for objects with automatic representation. Representations
1✔
303
    for instances are built from a list of attribute names specified as
304
    a class variable in ``__repr_attrs__`` (acting as a hook).
305

306
    Example:
307
        The representation (also hash) of instances of the following class
308
        is built based only on the attributes specified in ``__repr_attrs__``:
309

310
        >>> class MyClass(CCLAutoRepr):
311
            __repr_attrs__ = ("a", "b", "other")
312
            def __init__(self, a=1, b=2, c=3, d=4, e=5):
313
                self.a = a
314
                self.b = b
315
                self.c = c
316
                self.other = d + e
317

318
        >>> repr(MyClass(6, 7, 8, 9, 10))
319
            <__main__.MyClass>
320
                a = 6
321
                b = 7
322
                other = 19
323
    """
324

325
    def __repr__(self):
1✔
326
        # Build string from specified `__repr_attrs__` or use Python's default.
327
        # Subclasses overriding `__repr__`, stop using `__repr_attrs__`.
328
        if hasattr(self.__class__, "__repr_attrs__"):
1✔
329
            from .repr_ import build_string_from_attrs
1✔
330
            return build_string_from_attrs(self)
1✔
331
        return object.__repr__(self)
1✔
332

333

334
class CCLNamedClass(CCLObject):
1✔
335
    """Base for objects that contain methods ``from_name()`` and
1✔
336
    ``create_instance()``.
337

338
    Implementation
339
    --------------
340
    Subclasses must define a ``name`` class attribute which allows the tree to
341
    be searched to retrieve the particular model, using its name.
342
    """
343

344
    @property
1✔
345
    @abstractmethod
1✔
346
    def name(self) -> str:
1✔
347
        """Class attribute denoting the name of the model."""
348

349
    @classmethod
1✔
350
    def _subclasses(cls):
1✔
351
        # This helper returns a set of all subclasses.
352
        direct_subs = cls.__subclasses__()
1✔
353
        deep_subs = [sub for cl in direct_subs for sub in cl._subclasses()]
1✔
354
        return set(direct_subs).union(deep_subs)
1✔
355

356
    @classmethod
1✔
357
    def from_name(cls, name):
1✔
358
        """Obtain particular model."""
359
        mod = {p.name: p for p in cls._subclasses() if hasattr(p, "name")}
1✔
360
        if name not in mod:
1✔
361
            raise KeyError(f"Invalid model {name}.")
1✔
362
        return mod[name]
1✔
363

364
    @classmethod
1✔
365
    def create_instance(cls, input_, **kwargs):
1✔
366
        """Process the input and generate an object of the class.
367
        Input can be an instance of the class, or a name string.
368
        Optional ``**kwargs`` may be passed.
369
        """
370
        if isinstance(input_, cls):
1✔
371
            return input_
1✔
372
        if isinstance(input_, str):
1✔
373
            class_ = cls.from_name(input_)
1✔
374
            return class_(**kwargs)
1✔
375
        good, bad = cls.__name__, type(input_).__name__
×
376
        raise TypeError(f"Expected {good} or str but received {bad}.")
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc