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.
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ .env
9
+ .pytest_cache/
10
+ config.yaml
11
+ logs/
@@ -0,0 +1,4 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-library-reactive-model
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.10
@@ -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,11 @@
1
+ from .computed import ComputedModel
2
+ from .ref import RefModel
3
+ from .dict_ref import DictRefModel
4
+ from .list_ref import ListRefModel
5
+
6
+ __all__ = [
7
+ "RefModel",
8
+ "ComputedModel",
9
+ "DictRefModel",
10
+ "ListRefModel",
11
+ ]
@@ -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,10 @@
1
+ @echo off
2
+ cd /d %~dp0
3
+
4
+ if not exist .venv (
5
+ python -m venv .venv
6
+ )
7
+
8
+ call .venv\Scripts\activate.bat
9
+ pip install -e .
10
+ python -m unittest discover -s tests -p "test*.py" -v
@@ -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()