python-library-reactive-model 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_library_reactive_model-0.1.0/.gitignore +11 -0
- python_library_reactive_model-0.1.0/PKG-INFO +4 -0
- python_library_reactive_model-0.1.0/pyproject.toml +12 -0
- python_library_reactive_model-0.1.0/reactive_model/__init__.py +11 -0
- python_library_reactive_model-0.1.0/reactive_model/computed.py +55 -0
- python_library_reactive_model-0.1.0/reactive_model/dict_ref.py +102 -0
- python_library_reactive_model-0.1.0/reactive_model/list_ref.py +82 -0
- python_library_reactive_model-0.1.0/reactive_model/reactive.py +27 -0
- python_library_reactive_model-0.1.0/reactive_model/ref.py +29 -0
- python_library_reactive_model-0.1.0/reactive_model/track.py +85 -0
- python_library_reactive_model-0.1.0/test.bat +10 -0
- python_library_reactive_model-0.1.0/tests/test_reactive_model.py +111 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "python-library-reactive-model"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
dependencies = []
|
|
10
|
+
|
|
11
|
+
[tool.hatch.build.targets.wheel]
|
|
12
|
+
packages = ["reactive_model"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable, TypeVar, cast
|
|
4
|
+
|
|
5
|
+
from .reactive import ReactiveModel
|
|
6
|
+
from .track import Collector, Trackable, compute_context, track
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
_MISSING = object()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ComputedModel(ReactiveModel[T]):
|
|
13
|
+
def __init__(self, expr: Callable[[], T]) -> None:
|
|
14
|
+
super().__init__()
|
|
15
|
+
self._expr = expr
|
|
16
|
+
self._cache: T | object = _MISSING
|
|
17
|
+
self._deps: dict[int, tuple[Trackable, int]] = {}
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def value(self) -> T:
|
|
21
|
+
if self._needs_recompute():
|
|
22
|
+
self._recompute()
|
|
23
|
+
track(self)
|
|
24
|
+
return cast(T, self._cache)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def version(self) -> int:
|
|
28
|
+
if self._needs_recompute():
|
|
29
|
+
self._recompute()
|
|
30
|
+
return super().version
|
|
31
|
+
|
|
32
|
+
def _needs_recompute(self) -> bool:
|
|
33
|
+
if self._cache is _MISSING:
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
for dep, captured_version in self._deps.values():
|
|
37
|
+
if dep.version != captured_version:
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
def _recompute(self) -> None:
|
|
43
|
+
collector = Collector()
|
|
44
|
+
|
|
45
|
+
with compute_context(self, collector):
|
|
46
|
+
new_value = self._expr()
|
|
47
|
+
|
|
48
|
+
new_deps: dict[int, tuple[Trackable, int]] = {}
|
|
49
|
+
for dep_id, dep in collector.deps.items():
|
|
50
|
+
new_deps[dep_id] = (dep, dep.version)
|
|
51
|
+
|
|
52
|
+
self._deps = new_deps
|
|
53
|
+
self._cache = new_value
|
|
54
|
+
|
|
55
|
+
self.touch()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Iterator, MutableMapping
|
|
4
|
+
from typing import Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
from .ref import RefModel
|
|
7
|
+
from .track import track
|
|
8
|
+
|
|
9
|
+
K = TypeVar("K")
|
|
10
|
+
V = TypeVar("V")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DictProxy(MutableMapping[K, V], Generic[K, V]):
|
|
14
|
+
def __init__(self, owner: "DictRefModel[K, V]") -> None:
|
|
15
|
+
self._owner = owner
|
|
16
|
+
|
|
17
|
+
def __getitem__(self, key: K) -> V:
|
|
18
|
+
return self._owner._value[key]
|
|
19
|
+
|
|
20
|
+
def __setitem__(self, key: K, value: V) -> None:
|
|
21
|
+
self._owner._value[key] = value
|
|
22
|
+
self._owner.touch()
|
|
23
|
+
|
|
24
|
+
def __delitem__(self, key: K) -> None:
|
|
25
|
+
del self._owner._value[key]
|
|
26
|
+
self._owner.touch()
|
|
27
|
+
|
|
28
|
+
def __iter__(self) -> Iterator[K]:
|
|
29
|
+
return iter(self._owner._value)
|
|
30
|
+
|
|
31
|
+
def __len__(self) -> int:
|
|
32
|
+
return len(self._owner._value)
|
|
33
|
+
|
|
34
|
+
def clear(self) -> None:
|
|
35
|
+
if self._owner._value:
|
|
36
|
+
self._owner._value.clear()
|
|
37
|
+
self._owner.touch()
|
|
38
|
+
|
|
39
|
+
def update(self, other=(), /, **kwargs: V) -> None:
|
|
40
|
+
changed = False
|
|
41
|
+
|
|
42
|
+
if other:
|
|
43
|
+
if hasattr(other, "items"):
|
|
44
|
+
for k, v in other.items():
|
|
45
|
+
self._owner._value[k] = v
|
|
46
|
+
changed = True
|
|
47
|
+
else:
|
|
48
|
+
for k, v in other:
|
|
49
|
+
self._owner._value[k] = v
|
|
50
|
+
changed = True
|
|
51
|
+
|
|
52
|
+
for k, v in kwargs.items():
|
|
53
|
+
self._owner._value[k] = v
|
|
54
|
+
changed = True
|
|
55
|
+
|
|
56
|
+
if changed:
|
|
57
|
+
self._owner.touch()
|
|
58
|
+
|
|
59
|
+
def pop(self, key: K, default=...):
|
|
60
|
+
if default is ...:
|
|
61
|
+
value = self._owner._value.pop(key)
|
|
62
|
+
self._owner.touch()
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
if key in self._owner._value:
|
|
66
|
+
value = self._owner._value.pop(key)
|
|
67
|
+
self._owner.touch()
|
|
68
|
+
return value
|
|
69
|
+
return default
|
|
70
|
+
|
|
71
|
+
def popitem(self) -> tuple[K, V]:
|
|
72
|
+
item = self._owner._value.popitem()
|
|
73
|
+
self._owner.touch()
|
|
74
|
+
return item
|
|
75
|
+
|
|
76
|
+
def setdefault(self, key: K, default: V = None): # type: ignore[assignment]
|
|
77
|
+
if key in self._owner._value:
|
|
78
|
+
return self._owner._value[key]
|
|
79
|
+
self._owner._value[key] = default
|
|
80
|
+
self._owner.touch()
|
|
81
|
+
return default
|
|
82
|
+
|
|
83
|
+
def __repr__(self) -> str:
|
|
84
|
+
return repr(self._owner._value)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class DictRefModel(RefModel[dict[K, V]]):
|
|
88
|
+
def __init__(self, value: dict[K, V] | None = None) -> None:
|
|
89
|
+
if value is None:
|
|
90
|
+
value = {}
|
|
91
|
+
super().__init__(value)
|
|
92
|
+
self._proxy = DictProxy(self)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def value(self) -> DictProxy[K, V]:
|
|
96
|
+
track(self)
|
|
97
|
+
return self._proxy
|
|
98
|
+
|
|
99
|
+
@value.setter
|
|
100
|
+
def value(self, value: dict[K, V]) -> None:
|
|
101
|
+
self._value = value
|
|
102
|
+
self.touch()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, MutableSequence
|
|
4
|
+
from typing import Generic, TypeVar, overload
|
|
5
|
+
|
|
6
|
+
from .ref import RefModel
|
|
7
|
+
from .track import track
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ListProxy(MutableSequence[T], Generic[T]):
|
|
13
|
+
def __init__(self, owner: "ListRefModel[T]") -> None:
|
|
14
|
+
self._owner = owner
|
|
15
|
+
|
|
16
|
+
def __len__(self) -> int:
|
|
17
|
+
return len(self._owner._value)
|
|
18
|
+
|
|
19
|
+
@overload
|
|
20
|
+
def __getitem__(self, index: int) -> T: ...
|
|
21
|
+
@overload
|
|
22
|
+
def __getitem__(self, index: slice) -> list[T]: ...
|
|
23
|
+
|
|
24
|
+
def __getitem__(self, index: int | slice) -> T | list[T]:
|
|
25
|
+
return self._owner._value[index]
|
|
26
|
+
|
|
27
|
+
@overload
|
|
28
|
+
def __setitem__(self, index: int, value: T) -> None: ...
|
|
29
|
+
@overload
|
|
30
|
+
def __setitem__(self, index: slice, value: Iterable[T]) -> None: ...
|
|
31
|
+
|
|
32
|
+
def __setitem__(self, index: int | slice, value: T | Iterable[T]) -> None:
|
|
33
|
+
self._owner._value[index] = value # type: ignore[index,assignment]
|
|
34
|
+
self._owner.touch()
|
|
35
|
+
|
|
36
|
+
def __delitem__(self, index: int | slice) -> None:
|
|
37
|
+
del self._owner._value[index]
|
|
38
|
+
self._owner.touch()
|
|
39
|
+
|
|
40
|
+
def insert(self, index: int, value: T) -> None:
|
|
41
|
+
self._owner._value.insert(index, value)
|
|
42
|
+
self._owner.touch()
|
|
43
|
+
|
|
44
|
+
def clear(self) -> None:
|
|
45
|
+
if self._owner._value:
|
|
46
|
+
self._owner._value.clear()
|
|
47
|
+
self._owner.touch()
|
|
48
|
+
|
|
49
|
+
def extend(self, values: Iterable[T]) -> None:
|
|
50
|
+
data = list(values)
|
|
51
|
+
if data:
|
|
52
|
+
self._owner._value.extend(data)
|
|
53
|
+
self._owner.touch()
|
|
54
|
+
|
|
55
|
+
def sort(self, *, key=None, reverse: bool = False) -> None:
|
|
56
|
+
self._owner._value.sort(key=key, reverse=reverse)
|
|
57
|
+
self._owner.touch()
|
|
58
|
+
|
|
59
|
+
def reverse(self) -> None:
|
|
60
|
+
self._owner._value.reverse()
|
|
61
|
+
self._owner.touch()
|
|
62
|
+
|
|
63
|
+
def __repr__(self) -> str:
|
|
64
|
+
return repr(self._owner._value)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ListRefModel(RefModel[list[T]]):
|
|
68
|
+
def __init__(self, value: list[T] | None = None) -> None:
|
|
69
|
+
if value is None:
|
|
70
|
+
value = []
|
|
71
|
+
super().__init__(value)
|
|
72
|
+
self._proxy = ListProxy(self)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def value(self) -> ListProxy[T]:
|
|
76
|
+
track(self)
|
|
77
|
+
return self._proxy
|
|
78
|
+
|
|
79
|
+
@value.setter
|
|
80
|
+
def value(self, value: list[T]) -> None:
|
|
81
|
+
self._value = value
|
|
82
|
+
self.touch()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Generic, TypeVar
|
|
3
|
+
|
|
4
|
+
T = TypeVar("T")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ReactiveModel(Generic[T]):
|
|
8
|
+
def __init__(self) -> None:
|
|
9
|
+
"""
|
|
10
|
+
Attributes:
|
|
11
|
+
_version: 版本号
|
|
12
|
+
"""
|
|
13
|
+
self._version = 0
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def version(self) -> int:
|
|
17
|
+
"""版本号"""
|
|
18
|
+
return self._version
|
|
19
|
+
|
|
20
|
+
def touch(self) -> None:
|
|
21
|
+
"""标记为改动"""
|
|
22
|
+
self._version += 1
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def value(self) -> T:
|
|
26
|
+
"""值"""
|
|
27
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TypeVar
|
|
3
|
+
|
|
4
|
+
from .reactive import ReactiveModel
|
|
5
|
+
from .track import track
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RefModel(ReactiveModel[T]):
|
|
11
|
+
def __init__(self, value: T) -> None:
|
|
12
|
+
"""
|
|
13
|
+
Args:
|
|
14
|
+
value: 值
|
|
15
|
+
Attributes:
|
|
16
|
+
_value: 值
|
|
17
|
+
"""
|
|
18
|
+
super().__init__()
|
|
19
|
+
self._value = value
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def value(self) -> T:
|
|
23
|
+
track(self)
|
|
24
|
+
return self._value
|
|
25
|
+
|
|
26
|
+
@value.setter
|
|
27
|
+
def value(self, value: T) -> None:
|
|
28
|
+
self._value = value
|
|
29
|
+
self.touch()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from contextvars import ContextVar
|
|
5
|
+
import inspect
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Trackable(Protocol):
|
|
10
|
+
@property
|
|
11
|
+
def version(self) -> int: ...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Collector:
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self.deps: dict[int, Trackable] = {}
|
|
17
|
+
|
|
18
|
+
def add(self, dep: Trackable) -> None:
|
|
19
|
+
self.deps[id(dep)] = dep
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CircularDependencyError(RuntimeError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_ACTIVE_COLLECTOR: ContextVar[Collector | None] = ContextVar(
|
|
27
|
+
"_ACTIVE_COLLECTOR",
|
|
28
|
+
default=None,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_COMPUTE_STACK: ContextVar[tuple[object, ...]] = ContextVar(
|
|
32
|
+
"_COMPUTE_STACK",
|
|
33
|
+
default=(),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def track(dep: Trackable) -> None:
|
|
38
|
+
collector = _ACTIVE_COLLECTOR.get()
|
|
39
|
+
if collector is not None:
|
|
40
|
+
collector.add(dep)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _format_expr(expr: object) -> str:
|
|
44
|
+
file: str | None = None
|
|
45
|
+
line: int | None = None
|
|
46
|
+
code: str | None = None
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
file = inspect.getsourcefile(expr) or inspect.getfile(expr)
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
lines, line = inspect.getsourcelines(expr)
|
|
55
|
+
code = "".join(lines).strip()
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
location = "<unknown>" if file is None else f"{file}:{line}" if line is not None else file
|
|
60
|
+
return f"{location} -> {code}" if code else location
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _build_cycle_message(cycle: tuple[object, ...]) -> str:
|
|
64
|
+
lines = ["检测到循环依赖:"]
|
|
65
|
+
for idx, item in enumerate(cycle, start=1):
|
|
66
|
+
expr = getattr(item, "_expr", None)
|
|
67
|
+
lines.append(f"{idx}. {_format_expr(expr)}")
|
|
68
|
+
return "\n".join(lines)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@contextmanager
|
|
72
|
+
def compute_context(owner: object, collector: Collector):
|
|
73
|
+
stack = _COMPUTE_STACK.get()
|
|
74
|
+
if owner in stack:
|
|
75
|
+
start = stack.index(owner)
|
|
76
|
+
cycle = stack[start:] + (owner,)
|
|
77
|
+
raise CircularDependencyError(_build_cycle_message(cycle))
|
|
78
|
+
|
|
79
|
+
collector_token = _ACTIVE_COLLECTOR.set(collector)
|
|
80
|
+
stack_token = _COMPUTE_STACK.set(stack + (owner,))
|
|
81
|
+
try:
|
|
82
|
+
yield
|
|
83
|
+
finally:
|
|
84
|
+
_COMPUTE_STACK.reset(stack_token)
|
|
85
|
+
_ACTIVE_COLLECTOR.reset(collector_token)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from reactive_model import ComputedModel, DictRefModel, ListRefModel, RefModel
|
|
6
|
+
from reactive_model.track import CircularDependencyError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestRefModel(unittest.TestCase):
|
|
10
|
+
def test_get_set(self) -> None:
|
|
11
|
+
r = RefModel(10)
|
|
12
|
+
self.assertEqual(r.value, 10)
|
|
13
|
+
r.value = 20
|
|
14
|
+
self.assertEqual(r.value, 20)
|
|
15
|
+
|
|
16
|
+
def test_touch_bumps_version(self) -> None:
|
|
17
|
+
r = RefModel(0)
|
|
18
|
+
v0 = r.version
|
|
19
|
+
r.value = 1
|
|
20
|
+
self.assertGreater(r.version, v0)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestComputedModel(unittest.TestCase):
|
|
24
|
+
def test_lazy_and_cache(self) -> None:
|
|
25
|
+
calls: list[int] = []
|
|
26
|
+
|
|
27
|
+
def expr() -> int:
|
|
28
|
+
calls.append(1)
|
|
29
|
+
return 42
|
|
30
|
+
|
|
31
|
+
c = ComputedModel(expr)
|
|
32
|
+
self.assertEqual(calls, [])
|
|
33
|
+
self.assertEqual(c.value, 42)
|
|
34
|
+
self.assertEqual(calls, [1])
|
|
35
|
+
self.assertEqual(c.value, 42)
|
|
36
|
+
self.assertEqual(calls, [1])
|
|
37
|
+
|
|
38
|
+
def test_invalidates_when_dep_changes(self) -> None:
|
|
39
|
+
count = RefModel(1)
|
|
40
|
+
double = ComputedModel(lambda: count.value * 2)
|
|
41
|
+
self.assertEqual(double.value, 2)
|
|
42
|
+
v_after_first = double.version
|
|
43
|
+
count.value = 2
|
|
44
|
+
self.assertGreater(count.version, 0)
|
|
45
|
+
self.assertEqual(double.value, 4)
|
|
46
|
+
self.assertGreaterEqual(double.version, v_after_first)
|
|
47
|
+
|
|
48
|
+
def test_chained_computed(self) -> None:
|
|
49
|
+
count = RefModel(1)
|
|
50
|
+
double = ComputedModel(lambda: count.value * 2)
|
|
51
|
+
label = ComputedModel(lambda: f"double={double.value}")
|
|
52
|
+
self.assertEqual(label.value, "double=2")
|
|
53
|
+
count.value = 2
|
|
54
|
+
self.assertEqual(double.value, 4)
|
|
55
|
+
self.assertEqual(label.value, "double=4")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestCircularDependency(unittest.TestCase):
|
|
59
|
+
def test_mutual_computed_raises(self) -> None:
|
|
60
|
+
a_holder: list[ComputedModel[int] | None] = [None]
|
|
61
|
+
b_holder: list[ComputedModel[int] | None] = [None]
|
|
62
|
+
|
|
63
|
+
def a_expr() -> int:
|
|
64
|
+
assert b_holder[0] is not None
|
|
65
|
+
return b_holder[0].value
|
|
66
|
+
|
|
67
|
+
def b_expr() -> int:
|
|
68
|
+
assert a_holder[0] is not None
|
|
69
|
+
return a_holder[0].value
|
|
70
|
+
|
|
71
|
+
a_holder[0] = ComputedModel(a_expr)
|
|
72
|
+
b_holder[0] = ComputedModel(b_expr)
|
|
73
|
+
|
|
74
|
+
with self.assertRaises(CircularDependencyError):
|
|
75
|
+
_ = a_holder[0].value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestDictRefModel(unittest.TestCase):
|
|
79
|
+
def test_proxy_mutation_invalidates_computed(self) -> None:
|
|
80
|
+
d = DictRefModel({"x": 1})
|
|
81
|
+
total = ComputedModel(lambda: sum(d.value.values()))
|
|
82
|
+
self.assertEqual(total.value, 1)
|
|
83
|
+
d.value["x"] = 10
|
|
84
|
+
self.assertEqual(total.value, 10)
|
|
85
|
+
|
|
86
|
+
def test_replace_whole_dict(self) -> None:
|
|
87
|
+
d = DictRefModel({"a": 1})
|
|
88
|
+
keys = ComputedModel(lambda: sorted(d.value.keys()))
|
|
89
|
+
self.assertEqual(keys.value, ["a"])
|
|
90
|
+
d.value = {"b": 2}
|
|
91
|
+
self.assertEqual(keys.value, ["b"])
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestListRefModel(unittest.TestCase):
|
|
95
|
+
def test_proxy_mutation_invalidates_computed(self) -> None:
|
|
96
|
+
lst = ListRefModel([1, 2, 3])
|
|
97
|
+
s = ComputedModel(lambda: sum(lst.value))
|
|
98
|
+
self.assertEqual(s.value, 6)
|
|
99
|
+
lst.value.append(4)
|
|
100
|
+
self.assertEqual(s.value, 10)
|
|
101
|
+
|
|
102
|
+
def test_replace_whole_list(self) -> None:
|
|
103
|
+
lst = ListRefModel([1])
|
|
104
|
+
length = ComputedModel(lambda: len(lst.value))
|
|
105
|
+
self.assertEqual(length.value, 1)
|
|
106
|
+
lst.value = [1, 2, 3]
|
|
107
|
+
self.assertEqual(length.value, 3)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
unittest.main()
|