diff --git a/orangecanvas/scheme/signalmanager.py b/orangecanvas/scheme/signalmanager.py index 8a1d4faf..c5e574d7 100644 --- a/orangecanvas/scheme/signalmanager.py +++ b/orangecanvas/scheme/signalmanager.py @@ -11,6 +11,7 @@ import logging import warnings import enum +import functools from collections import defaultdict from operator import attrgetter @@ -19,7 +20,7 @@ import typing from typing import ( - Any, Optional, List, NamedTuple, Set, Dict, + Any, Optional, List, NamedTuple, Set, Dict, Callable, Sequence, Union, DefaultDict, Type ) @@ -95,6 +96,138 @@ class Close(Signal): ... is_enabled = attrgetter("enabled") +class _LazyValueType: + """ + LazyValue is an abstract type for wrapper for lazy evaluation of signals. + + LazyValue is intended for situations in which computation of outputs is + reasonably fast, but we won't to compute it only if the output is connected + to some input, in order to save memory. + + Assume the widget has a method `commit` that outputs the sum of two objects, + `self.a` and `self.b`. The output signal is names `signal_name` and its + type is `SomeType`. + + ``` + def commit(self): + self.send(self.Outputs.signal_name, self.a + self.b) + ``` + + To use lazy values, we modify the method as follows. + + ``` + def commit(self): + def f(): + return self.a + self.b + + self.send(self.Outputs.signal_name, LazySignal[SomeType](f)) + ``` + + The lazy function receives no arguments, so `commit` will often prepare + some data accessible through closure or default arguments. After calling + the function, LazyValue will release the reference to function, which in + turn releases references to any data from closure or arguments. + + LazyValue is a singleton, used in similar way as generic classes from + typing. "Indexing" returns an instance of (internal) class `LazyValue_`. + Indexing is cached; `LazyValue[SomeType]` always returns the same object. + + LazySignal[SomeType] (that is: LazyValue_) has a constructor that expects + the following arguments. + + - A function that computes the actual value. This function must expect + no arguments, but will usually get data (for instance `self`, in the + above example) from closure. + - An optional function that can be called to interrupt the computation. + This function is called when the signal is deleted. + - Optional extra arguments that are stored as LazyValue's attributes. + These are not accessible by the above function and are primarily + intended to be used in output summaries. + + Properties: + + - `is_cached()`, which returns `True` if the value is already computed. + Functions for output summaries can use this to show more information + if the value is available, and avoid computing it when not. + + Methods: + + - `get_value()` returns the actual value by calling the function, if + the value has not been computed yet, or providing the cached value. + - `type()` returns the type of the lazy signal (e.g. `SomeType`, in + above case. + """ + + class LazyValueMeta(type): + def __repr__(cls): + """ + Pretty-prints the LazyValue[SomeType] as "LazyValue[SomeType]" + instead of generic `LazyValue_`. + """ + return f"LazyValue[{cls.type().__name__}]" + + @classmethod + def is_lazy(cls, value): + """ + Tells whether the given value is lazy. + + ``` + >>> def f(): + ... return 12 + ... + >>> lazy = LazyValue[int](f) + >>> eager = f() + >>> LazyValue.is_lazy(lazy) + True + >>> LazyValue.is_lazy(eager) + False + ``` + """ + return isinstance(type(value), cls.LazyValueMeta) + + @classmethod + @functools.lru_cache(maxsize=None) + def __getitem__(cls, type_): + # This is cached, so that it always returns the same class for the + # same type. + # >>> t1 = LazyValue[int] + # >>> t2 = LazyValue[int] + # >>> t1 is t2 + # True + class LazyValue_(metaclass=cls.LazyValueMeta): + __type = type_ + + def __init__(self, func: Callable, interrupt=None, **extra_attrs): + self.__func = func + self.__cached = None + self.interrupt = interrupt + self.__dict__.update(extra_attrs) + + def __del__(self): + if self.interrupt is not None: + self.interrupt() + + @property + def is_cached(self): + return self.__func is None + + @classmethod + def type(cls): + return cls.__type + + def get_value(self): + if self.__func is not None: + self.__cached = self.__func() + # This frees any references to closure and arguments + self.__func = None + return self.__cached + + return LazyValue_ + + +LazyValue = _LazyValueType() + + class _OutputState: """Output state for a single node/channel""" __slots__ = ('flags', 'outputs') diff --git a/orangecanvas/scheme/tests/test_signalmanager.py b/orangecanvas/scheme/tests/test_signalmanager.py index e9431fc6..ce945d85 100644 --- a/orangecanvas/scheme/tests/test_signalmanager.py +++ b/orangecanvas/scheme/tests/test_signalmanager.py @@ -1,13 +1,79 @@ +import sys +import unittest +from unittest.mock import Mock + from AnyQt.QtTest import QSignalSpy from orangecanvas.scheme import Scheme, SchemeNode, SchemeLink from orangecanvas.scheme.signalmanager import ( - SignalManager, Signal, compress_signals, compress_single + SignalManager, Signal, compress_signals, compress_single, LazyValue ) from orangecanvas.registry import tests as registry_tests from orangecanvas.gui.test import QCoreAppTestCase +class TestLazyValue(unittest.TestCase): + def test_singletonnes(self): + i1 = LazyValue[int] + i2 = LazyValue[int] + f1 = LazyValue[float] + self.assertIs(i1, i2) + self.assertIsNot(i1, f1) + self.assertIsNot(i2, f1) + + def test_repr(self): + self.assertEqual(repr(LazyValue[int]), "LazyValue[int]") + + def test_get_value_and_cached(self): + f = Mock(return_value=42) + + lazy = LazyValue[int](f) + f.assert_not_called() + self.assertFalse(lazy.is_cached) + + self.assertEqual(lazy.get_value(), 42) + f.assert_called_once() + self.assertTrue(lazy.is_cached) + + self.assertEqual(lazy.get_value(), 42) + f.assert_called_once() + self.assertTrue(lazy.is_cached) + + def test_type(self): + self.assertIs(LazyValue[int].type(), int) + self.assertIs(LazyValue[float].type(), float) + + def test_release_closure(self): + deleted = Mock() + + def commit(): + class S: + def __del__(self): + deleted() + + s = S() + + def f(): + return 42 + bool(s) + return LazyValue[int](f) + + lazy = commit() + deleted.assert_not_called() + lazy.get_value() + deleted.assert_called_once() + + def test_interrupt(self): + interrupt = Mock() + lazy = LazyValue[int](Mock(), interrupt) + del lazy + interrupt.assert_called_once() + + def test_extra_args(self): + lazy = LazyValue[int](Mock(), a=1, b=2) + self.assertEqual(lazy.a, 1) + self.assertEqual(lazy.b, 2) + + class TestingSignalManager(SignalManager): def is_blocking(self, node): return bool(node.property("-blocking"))