hmr 0.0.1__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.
hmr-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.1
2
+ Name: hmr
3
+ Version: 0.0.1
4
+ Summary: Hot Module Reload for Python
5
+ Project-URL: repository, https://github.com/promplate/pyth-on-line/tree/reactivity
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: watchfiles<2,>=0.21
8
+
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "hmr"
3
+ version = "0.0.1"
4
+ requires-python = ">=3.12"
5
+ description = "Hot Module Reload for Python"
6
+ dependencies = [
7
+ "watchfiles>=0.21,<2",
8
+ ]
9
+
10
+ [project.scripts]
11
+ hmr = "reactivity.hmr:cli"
12
+
13
+ [project.urls]
14
+ repository = "https://github.com/promplate/pyth-on-line/tree/reactivity"
15
+
16
+ [build-system]
17
+ requires = [
18
+ "pdm-backend",
19
+ ]
20
+ build-backend = "pdm.backend"
21
+
22
+ [tool]
@@ -0,0 +1,5 @@
1
+ from .functional import batch, create_effect, create_memo, create_signal, memoized_method, memoized_property
2
+ from .helpers import Reactive
3
+ from .primitives import Derived, State
4
+
5
+ __all__ = ["Derived", "Reactive", "State", "batch", "create_effect", "create_memo", "create_signal", "memoized_method", "memoized_property"]
@@ -0,0 +1,54 @@
1
+ from collections.abc import Callable
2
+ from functools import wraps
3
+ from typing import Protocol, overload
4
+
5
+ from .helpers import Memoized, MemoizedMethod, MemoizedProperty
6
+ from .primitives import Batch, Derived, Signal
7
+
8
+
9
+ class Getter[T](Protocol):
10
+ def __call__(self, track=True) -> T: ...
11
+
12
+
13
+ class Setter[T](Protocol):
14
+ def __call__(self, value: T): ...
15
+
16
+
17
+ def create_signal[T](initial_value: T = None, check_equality=True) -> tuple[Getter[T], Setter[T]]:
18
+ signal = Signal(initial_value, check_equality)
19
+ return signal.get, signal.set
20
+
21
+
22
+ def create_effect[T](fn: Callable[[], T], auto_run=True):
23
+ return Derived(fn, auto_run)
24
+
25
+
26
+ def create_memo[T](fn: Callable[[], T]):
27
+ return Memoized(fn)
28
+
29
+
30
+ def memoized_property[T, Self](method: Callable[[Self], T]):
31
+ return MemoizedProperty(method)
32
+
33
+
34
+ def memoized_method[T, Self](method: Callable[[Self], T]):
35
+ return MemoizedMethod(method)
36
+
37
+
38
+ @overload
39
+ def batch() -> Batch: ...
40
+ @overload
41
+ def batch[**P, T](func: Callable[P, T]) -> Callable[P, T]: ...
42
+
43
+
44
+ def batch[**P, T](func: Callable[P, T] | None = None) -> Callable[P, T] | Batch:
45
+ if func is not None:
46
+
47
+ @wraps(func)
48
+ def wrapped(*args, **kwargs):
49
+ with Batch():
50
+ return func(*args, **kwargs)
51
+
52
+ return wrapped
53
+
54
+ return Batch()
@@ -0,0 +1,110 @@
1
+ from collections.abc import Callable, Mapping, MutableMapping
2
+ from functools import partial
3
+ from weakref import WeakKeyDictionary
4
+
5
+ from .primitives import BaseComputation, Batch, Signal, Subscribable
6
+
7
+
8
+ class Memoized[T](Subscribable, BaseComputation):
9
+ def __init__(self, fn: Callable[[], T]):
10
+ super().__init__()
11
+ self.fn = fn
12
+ self.is_stale = True
13
+ self.cached_value: T
14
+ self._recompute = False
15
+
16
+ def trigger(self):
17
+ if self._recompute:
18
+ self._before()
19
+ self.cached_value = self.fn()
20
+ self._after()
21
+ self.is_stale = False
22
+ elif not self.is_stale:
23
+ del self.cached_value
24
+ self.is_stale = True
25
+ self.track()
26
+
27
+ def __call__(self):
28
+ if self.is_stale:
29
+ self._recompute = True
30
+ self.trigger()
31
+ self._recompute = False
32
+ return self.cached_value
33
+
34
+ def invalidate(self):
35
+ del self.cached_value
36
+ self.is_stale = True
37
+
38
+
39
+ class MemoizedProperty[T, Self]:
40
+ def __init__(self, method: Callable[[Self], T]):
41
+ super().__init__()
42
+ self.method = method
43
+ self.map = WeakKeyDictionary[Self, Memoized]()
44
+
45
+ def __get__(self, instance, owner):
46
+ if func := self.map.get(instance):
47
+ return func()
48
+ self.map[instance] = func = Memoized(partial(self.method, instance))
49
+ return func()
50
+
51
+
52
+ class MemoizedMethod[T, Self]:
53
+ def __init__(self, method: Callable[[Self], T]):
54
+ super().__init__()
55
+ self.method = method
56
+
57
+ def __get__(self, instance, owner):
58
+ return Memoized(partial(self.method, instance))
59
+
60
+
61
+ class Reactive[K, V](Subscribable, MutableMapping[K, V]):
62
+ UNSET: V = object() # type: ignore
63
+
64
+ def __hash__(self):
65
+ return id(self)
66
+
67
+ def __init__(self, initial: Mapping | None = None, check_equality=True):
68
+ super().__init__()
69
+ self._signals: dict[K, Signal[V]] = {} if initial is None else {k: Signal(v, check_equality) for k, v in initial.items()}
70
+ self._check_equality = check_equality
71
+
72
+ def __getitem__(self, key: K):
73
+ value = self._signals.setdefault(key, Signal(self.UNSET, self._check_equality)).get()
74
+ if value is self.UNSET:
75
+ raise KeyError(key)
76
+ return value
77
+
78
+ def __setitem__(self, key: K, value: V):
79
+ with Batch():
80
+ try:
81
+ self._signals[key].set(value)
82
+ except KeyError:
83
+ self._signals[key] = Signal(value, self._check_equality)
84
+ self._signals[key].set(value)
85
+ self.notify()
86
+
87
+ def __delitem__(self, key: K):
88
+ state = self._signals[key]
89
+ if state.get(track=False) is self.UNSET:
90
+ raise KeyError(key)
91
+ with Batch():
92
+ state.set(self.UNSET)
93
+ state.notify()
94
+ self.notify()
95
+
96
+ def __iter__(self):
97
+ self.track()
98
+ return iter(self._signals)
99
+
100
+ def __len__(self):
101
+ self.track()
102
+ return len(self._signals)
103
+
104
+ def __repr__(self):
105
+ self.track()
106
+ return repr({k: v.get() for k, v in self._signals.items()})
107
+
108
+ def items(self):
109
+ self.track()
110
+ return ({k: v.get() for k, v in self._signals.items()}).items()
@@ -0,0 +1,215 @@
1
+ import sys
2
+ from collections.abc import Iterable, Sequence
3
+ from contextlib import suppress
4
+ from functools import cached_property
5
+ from importlib.abc import Loader, MetaPathFinder
6
+ from importlib.machinery import ModuleSpec
7
+ from importlib.util import spec_from_loader
8
+ from inspect import currentframe
9
+ from pathlib import Path
10
+ from runpy import run_path
11
+ from types import ModuleType
12
+
13
+ from . import Reactive, batch, create_effect, memoized_method
14
+
15
+
16
+ def is_called_in_this_file() -> bool:
17
+ frame = currentframe() # this function
18
+ assert frame is not None
19
+
20
+ frame = frame.f_back # the function calling this function
21
+ assert frame is not None
22
+
23
+ frame = frame.f_back # the function calling the function calling this function
24
+ assert frame is not None
25
+
26
+ return frame.f_globals.get("__file__") == __file__
27
+
28
+
29
+ class ReactiveModule(ModuleType):
30
+ def __init__(self, file: Path, namespace: dict, name: str, doc: str | None = None):
31
+ super().__init__(name, doc)
32
+ self.__namespace = namespace
33
+ self.__namespace_proxy = Reactive(namespace)
34
+ self.__file = file
35
+
36
+ @property
37
+ def file(self):
38
+ if is_called_in_this_file():
39
+ return self.__file
40
+ raise AttributeError("file")
41
+
42
+ @property
43
+ def load(self):
44
+ if is_called_in_this_file():
45
+ return lambda: exec(self.__file.read_text("utf-8"), self.__namespace, self.__namespace_proxy)
46
+ raise AttributeError("load")
47
+
48
+ def __getattr__(self, name: str):
49
+ try:
50
+ return self.__namespace_proxy[name]
51
+ except KeyError as e:
52
+ raise AttributeError(*e.args) from e
53
+
54
+ def __setattr__(self, name: str, value):
55
+ if is_called_in_this_file():
56
+ return super().__setattr__(name, value)
57
+ self.__namespace_proxy[name] = value
58
+
59
+
60
+ class ReactiveModuleLoader(Loader):
61
+ def __init__(self, file: Path, is_package=False):
62
+ super().__init__()
63
+ self._file = file
64
+ self._is_package = is_package
65
+
66
+ def create_module(self, spec: ModuleSpec):
67
+ namespace = {}
68
+ if self._is_package:
69
+ assert self._file.name == "__init__.py"
70
+ namespace["__path__"] = [str(self._file.parent.parent)]
71
+ namespace["__package__"] = spec.name
72
+ return ReactiveModule(self._file, namespace, spec.name)
73
+
74
+ def exec_module(self, module: ModuleType):
75
+ assert isinstance(module, ReactiveModule)
76
+ create_effect(lambda: module.load())
77
+
78
+
79
+ class ReactiveModuleFinder(MetaPathFinder):
80
+ def __init__(self, includes: Iterable[str] = ".", excludes: Iterable[str] = ()):
81
+ super().__init__()
82
+ self.includes = [Path(i).resolve() for i in includes]
83
+ self.excludes = [Path(e).resolve() for e in excludes]
84
+
85
+ def find_spec(self, fullname: str, paths: Sequence[str] | None, _=None):
86
+ if fullname in sys.modules:
87
+ return None
88
+
89
+ for p in paths or sys.path:
90
+ directory = Path(p)
91
+ if directory.is_file():
92
+ continue
93
+ if any(directory.is_relative_to(exclude) for exclude in self.excludes):
94
+ continue
95
+ if all(not directory.is_relative_to(include) for include in self.includes):
96
+ continue
97
+
98
+ file = directory / f"{fullname.replace('.', '/')}.py"
99
+ if file.is_file() and all(not file.is_relative_to(exclude) for exclude in self.excludes):
100
+ return spec_from_loader(fullname, ReactiveModuleLoader(file), origin=str(file))
101
+ file = directory / f"{fullname.replace('.', '/')}/__init__.py"
102
+ if file.is_file() and all(not file.is_relative_to(exclude) for exclude in self.excludes):
103
+ return spec_from_loader(fullname, ReactiveModuleLoader(file, is_package=True), origin=str(file))
104
+
105
+
106
+ def patch_module(name_or_module: str | ModuleType):
107
+ name = name_or_module if isinstance(name_or_module, str) else name_or_module.__name__
108
+ module = sys.modules[name_or_module] if isinstance(name_or_module, str) else name_or_module
109
+ assert isinstance(module.__file__, str), f"{name} is not a file-backed module"
110
+ m = sys.modules[name] = ReactiveModule(Path(module.__file__), module.__dict__, module.__name__, module.__doc__)
111
+ return m
112
+
113
+
114
+ def patch_meta_path(includes: Iterable[str] = (".",), excludes: Iterable[str] = (".venv",)):
115
+ sys.meta_path.insert(0, ReactiveModuleFinder(includes, excludes))
116
+
117
+
118
+ def get_path_module_map():
119
+ return {module.file.resolve(): module for module in sys.modules.values() if isinstance(module, ReactiveModule)}
120
+
121
+
122
+ class BaseReloader:
123
+ def __init__(self, entry_file: str, includes: Iterable[str] = (".",), excludes: Iterable[str] = ()):
124
+ self.entry = entry_file
125
+ self.includes = includes
126
+ self.excludes = excludes
127
+ patch_meta_path(includes, excludes)
128
+
129
+ @memoized_method
130
+ def run_entry_file(self):
131
+ try:
132
+ run_path(self.entry, run_name="__main__")
133
+ except Exception as e:
134
+ sys.excepthook(e.__class__, e, e.__traceback__)
135
+
136
+ @property
137
+ def watch_filter(self):
138
+ from watchfiles import PythonFilter
139
+
140
+ return PythonFilter(ignore_paths=tuple(self.excludes))
141
+
142
+ def on_events(self, events: Iterable[tuple[int, str]]):
143
+ from watchfiles import Change
144
+
145
+ if not events:
146
+ return
147
+
148
+ path2module = get_path_module_map()
149
+
150
+ with batch():
151
+ for type, file in events:
152
+ if type is Change.modified:
153
+ path = Path(file).resolve()
154
+ if path.samefile(self.entry):
155
+ self.run_entry_file()
156
+ elif module := path2module.get(path):
157
+ try:
158
+ module.load()
159
+ except Exception as e:
160
+ sys.excepthook(e.__class__, e, e.__traceback__)
161
+
162
+
163
+ class SyncReloader(BaseReloader):
164
+ @cached_property
165
+ def _stop_event(self):
166
+ from threading import Event
167
+
168
+ return Event()
169
+
170
+ def stop_watching(self):
171
+ self._stop_event.set()
172
+ del self._stop_event
173
+
174
+ def start_watching(self):
175
+ from watchfiles import watch
176
+
177
+ for events in watch(self.entry, *self.includes, watch_filter=self.watch_filter, stop_event=self._stop_event):
178
+ self.on_events(events)
179
+
180
+ def keep_watching_until_interrupt(self):
181
+ with suppress(KeyboardInterrupt):
182
+ self.run_entry_file()
183
+ self.start_watching()
184
+ self.run_entry_file.dispose()
185
+
186
+
187
+ class AsyncReloader(BaseReloader):
188
+ @cached_property
189
+ def _stop_event(self):
190
+ from asyncio import Event
191
+
192
+ return Event()
193
+
194
+ def stop_watching(self):
195
+ self._stop_event.set()
196
+ del self._stop_event
197
+
198
+ async def start_watching(self):
199
+ from watchfiles import awatch
200
+
201
+ async for events in awatch(self.entry, *self.includes, watch_filter=self.watch_filter, stop_event=self._stop_event):
202
+ self.on_events(events)
203
+
204
+ async def keep_watching_until_interrupt(self):
205
+ with suppress(KeyboardInterrupt):
206
+ self.run_entry_file()
207
+ await self.start_watching()
208
+ self.run_entry_file.dispose()
209
+
210
+
211
+ def cli():
212
+ entry = sys.argv[-1]
213
+ assert Path(entry).is_file(), f"{entry} is not a file"
214
+ sys.path.insert(0, ".")
215
+ SyncReloader(entry, excludes={".venv"}).keep_watching_until_interrupt()
@@ -0,0 +1,133 @@
1
+ from collections.abc import Callable
2
+ from typing import Any
3
+ from weakref import WeakKeyDictionary, WeakSet
4
+
5
+
6
+ class Subscribable:
7
+ def __init__(self):
8
+ super().__init__()
9
+ self.subscribers = set[BaseComputation]()
10
+
11
+ def track(self):
12
+ for computation in _current_computations:
13
+ if computation is not self:
14
+ self.subscribers.add(computation)
15
+ computation.dependencies.add(self)
16
+
17
+ def notify(self):
18
+ if _batches:
19
+ _batches[-1].callbacks.extend(self.subscribers)
20
+ else:
21
+ for subscriber in {*self.subscribers}:
22
+ subscriber.trigger()
23
+
24
+
25
+ class BaseComputation:
26
+ def __init__(self):
27
+ super().__init__()
28
+ self.dependencies = WeakSet[Subscribable]()
29
+
30
+ def dispose(self):
31
+ for dep in self.dependencies:
32
+ dep.subscribers.remove(self)
33
+ self.dependencies.clear()
34
+
35
+ def _before(self):
36
+ self.dispose()
37
+ _current_computations.append(self)
38
+
39
+ def _after(self):
40
+ last = _current_computations.pop()
41
+ assert last is self # sanity check
42
+
43
+ def __enter__(self):
44
+ return self
45
+
46
+ def __exit__(self, *_):
47
+ self.dispose()
48
+
49
+ def trigger(self) -> Any: ...
50
+
51
+ def __call__(self):
52
+ return self.trigger()
53
+
54
+
55
+ _current_computations: list[BaseComputation] = []
56
+
57
+
58
+ class Signal[T](Subscribable):
59
+ def __init__(self, initial_value: T = None, check_equality=True):
60
+ super().__init__()
61
+ self._value: T = initial_value
62
+ self._check_equality = check_equality
63
+
64
+ def get(self, track=True):
65
+ if track:
66
+ self.track()
67
+ return self._value
68
+
69
+ def set(self, value: T):
70
+ if not self._check_equality or self._value != value:
71
+ self._value = value
72
+ self.notify()
73
+
74
+
75
+ class State[T](Signal[T]):
76
+ def __init__(self, initial_value: T = None, check_equality=True):
77
+ super().__init__(initial_value, check_equality)
78
+ self._value = initial_value
79
+ self._check_equality = check_equality
80
+ self.map = WeakKeyDictionary[Any, Signal[T]]()
81
+
82
+ def __get__(self, instance, owner):
83
+ try:
84
+ return self.map[instance].get()
85
+ except KeyError:
86
+ self.map[instance] = state = Signal(self._value, self._check_equality)
87
+ return state.get()
88
+
89
+ def __set__(self, instance, value: T):
90
+ try:
91
+ state = self.map[instance]
92
+ except KeyError:
93
+ self.map[instance] = state = Signal(self._value, self._check_equality)
94
+ state.set(value)
95
+
96
+
97
+ class Derived[T](BaseComputation):
98
+ def __init__(self, fn: Callable[[], T], auto_run=True):
99
+ super().__init__()
100
+
101
+ self._fn = fn
102
+
103
+ if auto_run:
104
+ self()
105
+
106
+ def trigger(self):
107
+ self._before()
108
+ try:
109
+ return self._fn()
110
+ finally:
111
+ self._after()
112
+
113
+
114
+ class Batch:
115
+ def __init__(self):
116
+ self.callbacks: list[BaseComputation] = []
117
+
118
+ def flush(self):
119
+ callbacks = set(self.callbacks)
120
+ self.callbacks.clear()
121
+ for computation in callbacks:
122
+ computation.trigger()
123
+
124
+ def __enter__(self):
125
+ _batches.append(self)
126
+
127
+ def __exit__(self, *_):
128
+ last = _batches.pop()
129
+ assert last is self
130
+ self.flush()
131
+
132
+
133
+ _batches: list[Batch] = []