pulse-framework 0.1.40__py3-none-any.whl → 0.1.42__py3-none-any.whl
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.
- pulse/__init__.py +19 -4
- pulse/app.py +159 -99
- pulse/channel.py +7 -7
- pulse/cli/cmd.py +81 -45
- pulse/cli/models.py +2 -0
- pulse/cli/processes.py +67 -22
- pulse/cli/uvicorn_log_config.py +1 -1
- pulse/codegen/codegen.py +14 -1
- pulse/codegen/templates/layout.py +10 -2
- pulse/context.py +3 -2
- pulse/decorators.py +132 -40
- pulse/form.py +9 -9
- pulse/helpers.py +75 -11
- pulse/hooks/core.py +7 -8
- pulse/hooks/init.py +460 -0
- pulse/hooks/states.py +91 -54
- pulse/messages.py +1 -1
- pulse/middleware.py +170 -119
- pulse/plugin.py +0 -3
- pulse/proxy.py +134 -16
- pulse/queries/__init__.py +0 -0
- pulse/queries/common.py +24 -0
- pulse/queries/mutation.py +142 -0
- pulse/queries/query.py +270 -0
- pulse/queries/query_observer.py +365 -0
- pulse/queries/store.py +60 -0
- pulse/react_component.py +2 -1
- pulse/reactive.py +153 -53
- pulse/render_session.py +5 -2
- pulse/routing.py +68 -10
- pulse/state.py +8 -7
- pulse/types/event_handler.py +2 -3
- pulse/user_session.py +3 -2
- pulse/vdom.py +3 -1
- {pulse_framework-0.1.40.dist-info → pulse_framework-0.1.42.dist-info}/METADATA +1 -1
- {pulse_framework-0.1.40.dist-info → pulse_framework-0.1.42.dist-info}/RECORD +38 -32
- pulse/query.py +0 -408
- {pulse_framework-0.1.40.dist-info → pulse_framework-0.1.42.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.40.dist-info → pulse_framework-0.1.42.dist-info}/entry_points.txt +0 -0
pulse/queries/common.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import (
|
|
3
|
+
Any,
|
|
4
|
+
Concatenate,
|
|
5
|
+
ParamSpec,
|
|
6
|
+
TypeVar,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
from pulse.state import State
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
TState = TypeVar("TState", bound="State")
|
|
13
|
+
P = ParamSpec("P")
|
|
14
|
+
R = TypeVar("R")
|
|
15
|
+
|
|
16
|
+
OnSuccessFn = Callable[[TState], Any] | Callable[[TState, T], Any]
|
|
17
|
+
OnErrorFn = Callable[[TState], Any] | Callable[[TState, Exception], Any]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def bind_state(
|
|
21
|
+
state: TState, fn: Callable[Concatenate[TState, P], R]
|
|
22
|
+
) -> Callable[P, R]:
|
|
23
|
+
"Type-safe helper to bind a method to a state"
|
|
24
|
+
return fn.__get__(state, state.__class__)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
from typing import (
|
|
3
|
+
Any,
|
|
4
|
+
Concatenate,
|
|
5
|
+
Generic,
|
|
6
|
+
ParamSpec,
|
|
7
|
+
TypeVar,
|
|
8
|
+
override,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from pulse.helpers import call_flexible, maybe_await
|
|
12
|
+
from pulse.queries.common import OnErrorFn, OnSuccessFn, bind_state
|
|
13
|
+
from pulse.reactive import Signal
|
|
14
|
+
from pulse.state import InitializableProperty, State
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
TState = TypeVar("TState", bound=State)
|
|
18
|
+
R = TypeVar("R")
|
|
19
|
+
P = ParamSpec("P")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MutationResult(Generic[T, P]):
|
|
23
|
+
"""
|
|
24
|
+
Result object for mutations that provides reactive access to mutation state
|
|
25
|
+
and is callable to execute the mutation.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
_data: Signal[T | None]
|
|
29
|
+
_is_running: Signal[bool]
|
|
30
|
+
_error: Signal[Exception | None]
|
|
31
|
+
_fn: Callable[P, Awaitable[T]]
|
|
32
|
+
_on_success: Callable[[T], Any] | None
|
|
33
|
+
_on_error: Callable[[Exception], Any] | None
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
fn: Callable[P, Awaitable[T]],
|
|
38
|
+
on_success: Callable[[T], Any] | None = None,
|
|
39
|
+
on_error: Callable[[Exception], Any] | None = None,
|
|
40
|
+
):
|
|
41
|
+
self._data = Signal(None, name="mutation.data")
|
|
42
|
+
self._is_running = Signal(False, name="mutation.is_running")
|
|
43
|
+
self._error = Signal(None, name="mutation.error")
|
|
44
|
+
self._fn = fn
|
|
45
|
+
self._on_success = on_success
|
|
46
|
+
self._on_error = on_error
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def data(self) -> T | None:
|
|
50
|
+
return self._data()
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def is_running(self) -> bool:
|
|
54
|
+
return self._is_running()
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def error(self) -> Exception | None:
|
|
58
|
+
return self._error()
|
|
59
|
+
|
|
60
|
+
async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T:
|
|
61
|
+
self._is_running.write(True)
|
|
62
|
+
self._error.write(None)
|
|
63
|
+
try:
|
|
64
|
+
mutation_result = await self._fn(*args, **kwargs)
|
|
65
|
+
self._data.write(mutation_result)
|
|
66
|
+
if self._on_success:
|
|
67
|
+
await maybe_await(call_flexible(self._on_success, mutation_result))
|
|
68
|
+
return mutation_result
|
|
69
|
+
except Exception as e:
|
|
70
|
+
self._error.write(e)
|
|
71
|
+
if self._on_error:
|
|
72
|
+
await maybe_await(call_flexible(self._on_error, e))
|
|
73
|
+
raise e
|
|
74
|
+
finally:
|
|
75
|
+
self._is_running.write(False)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class MutationProperty(Generic[T, TState, P], InitializableProperty):
|
|
79
|
+
_on_success_fn: Callable[[TState, T], Any] | None
|
|
80
|
+
_on_error_fn: Callable[[TState, Exception], Any] | None
|
|
81
|
+
name: str
|
|
82
|
+
fn: Callable[Concatenate[TState, P], Awaitable[T]]
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
name: str,
|
|
87
|
+
fn: Callable[Concatenate[TState, P], Awaitable[T]],
|
|
88
|
+
on_success: OnSuccessFn[TState, T] | None = None,
|
|
89
|
+
on_error: OnErrorFn[TState] | None = None,
|
|
90
|
+
):
|
|
91
|
+
self.name = name
|
|
92
|
+
self.fn = fn
|
|
93
|
+
self._on_success_fn = on_success # pyright: ignore[reportAttributeAccessIssue]
|
|
94
|
+
self._on_error_fn = on_error # pyright: ignore[reportAttributeAccessIssue]
|
|
95
|
+
|
|
96
|
+
# Decorator to attach an on-success handler (sync or async)
|
|
97
|
+
def on_success(self, fn: OnSuccessFn[TState, T]):
|
|
98
|
+
if self._on_success_fn is not None:
|
|
99
|
+
raise RuntimeError(
|
|
100
|
+
f"Duplicate on_success() decorator for mutation '{self.name}'. Only one is allowed."
|
|
101
|
+
)
|
|
102
|
+
self._on_success_fn = fn # pyright: ignore[reportAttributeAccessIssue]
|
|
103
|
+
return fn
|
|
104
|
+
|
|
105
|
+
# Decorator to attach an on-error handler (sync or async)
|
|
106
|
+
def on_error(self, fn: OnErrorFn[TState]):
|
|
107
|
+
if self._on_error_fn is not None:
|
|
108
|
+
raise RuntimeError(
|
|
109
|
+
f"Duplicate on_error() decorator for mutation '{self.name}'. Only one is allowed."
|
|
110
|
+
)
|
|
111
|
+
self._on_error_fn = fn # pyright: ignore[reportAttributeAccessIssue]
|
|
112
|
+
return fn
|
|
113
|
+
|
|
114
|
+
def __get__(self, obj: Any, objtype: Any = None) -> MutationResult[T, P]:
|
|
115
|
+
if obj is None:
|
|
116
|
+
return self # pyright: ignore[reportReturnType]
|
|
117
|
+
|
|
118
|
+
# Cache the result on the instance
|
|
119
|
+
cache_key = f"__mutation_{self.name}"
|
|
120
|
+
if not hasattr(obj, cache_key):
|
|
121
|
+
# Bind methods to state
|
|
122
|
+
bound_fn = bind_state(obj, self.fn)
|
|
123
|
+
bound_on_success = (
|
|
124
|
+
bind_state(obj, self._on_success_fn) if self._on_success_fn else None
|
|
125
|
+
)
|
|
126
|
+
bound_on_error = (
|
|
127
|
+
bind_state(obj, self._on_error_fn) if self._on_error_fn else None
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
result = MutationResult[T, P](
|
|
131
|
+
fn=bound_fn,
|
|
132
|
+
on_success=bound_on_success,
|
|
133
|
+
on_error=bound_on_error,
|
|
134
|
+
)
|
|
135
|
+
setattr(obj, cache_key, result)
|
|
136
|
+
|
|
137
|
+
return getattr(obj, cache_key)
|
|
138
|
+
|
|
139
|
+
@override
|
|
140
|
+
def initialize(self, state: State, name: str) -> MutationResult[T, P]:
|
|
141
|
+
# For compatibility with InitializableProperty, but mutations don't need special initialization
|
|
142
|
+
return self.__get__(state, state.__class__)
|
pulse/queries/query.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from collections.abc import Awaitable, Callable, Hashable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import (
|
|
6
|
+
TYPE_CHECKING,
|
|
7
|
+
Any,
|
|
8
|
+
Generic,
|
|
9
|
+
Literal,
|
|
10
|
+
TypeAlias,
|
|
11
|
+
TypeVar,
|
|
12
|
+
cast,
|
|
13
|
+
override,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from pulse.helpers import (
|
|
17
|
+
MISSING,
|
|
18
|
+
Disposable,
|
|
19
|
+
call_flexible,
|
|
20
|
+
is_pytest,
|
|
21
|
+
later,
|
|
22
|
+
maybe_await,
|
|
23
|
+
)
|
|
24
|
+
from pulse.reactive import AsyncEffect, Computed, Signal
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from pulse.queries.query_observer import QueryResult
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
QueryKey: TypeAlias = tuple[Hashable, ...]
|
|
31
|
+
QueryStatus: TypeAlias = Literal["loading", "success", "error"]
|
|
32
|
+
QueryFetchStatus: TypeAlias = Literal["idle", "fetching", "paused"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AsyncQueryEffect(AsyncEffect):
|
|
36
|
+
"""
|
|
37
|
+
Specialized AsyncEffect for queries that synchronously sets loading state
|
|
38
|
+
when rescheduled/run.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
query: "Query[Any]"
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
fn: Callable[[], Awaitable[None]],
|
|
46
|
+
query: "Query[Any]",
|
|
47
|
+
name: str | None = None,
|
|
48
|
+
lazy: bool = False,
|
|
49
|
+
deps: list[Signal[Any] | Computed[Any]] | None = None,
|
|
50
|
+
):
|
|
51
|
+
self.query = query
|
|
52
|
+
super().__init__(fn, name=name, lazy=lazy, deps=deps)
|
|
53
|
+
|
|
54
|
+
@override
|
|
55
|
+
def run(self) -> asyncio.Task[Any]:
|
|
56
|
+
# Immediately set loading state before running the effect
|
|
57
|
+
self.query.fetch_status.write("fetching")
|
|
58
|
+
return super().run()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(slots=True)
|
|
62
|
+
class QueryConfig(Generic[T]):
|
|
63
|
+
retries: int
|
|
64
|
+
retry_delay: float
|
|
65
|
+
initial_data: T | Callable[[], T] | None
|
|
66
|
+
gc_time: float
|
|
67
|
+
on_dispose: Callable[[Any], None] | None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
RETRY_DELAY_DEFAULT = 2.0 if not is_pytest() else 0.01
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Query(Generic[T], Disposable):
|
|
74
|
+
"""
|
|
75
|
+
Represents a single query instance in a store.
|
|
76
|
+
Manages the async effect, data/status signals, and observer tracking.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
key: QueryKey | None
|
|
80
|
+
fn: Callable[[], Awaitable[T]]
|
|
81
|
+
cfg: QueryConfig[T]
|
|
82
|
+
|
|
83
|
+
# Reactive signals for query state
|
|
84
|
+
data: Signal[T | None]
|
|
85
|
+
error: Signal[Exception | None]
|
|
86
|
+
last_updated: Signal[float]
|
|
87
|
+
status: Signal[QueryStatus]
|
|
88
|
+
fetch_status: Signal[QueryFetchStatus]
|
|
89
|
+
retries: Signal[int]
|
|
90
|
+
retry_reason: Signal[Exception | None]
|
|
91
|
+
|
|
92
|
+
_observers: "list[QueryResult[T]]"
|
|
93
|
+
_effect: AsyncEffect | None
|
|
94
|
+
_gc_handle: asyncio.TimerHandle | None
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
key: QueryKey | None,
|
|
99
|
+
fn: Callable[[], Awaitable[T]],
|
|
100
|
+
retries: int = 3,
|
|
101
|
+
retry_delay: float = RETRY_DELAY_DEFAULT,
|
|
102
|
+
initial_data: T | None = MISSING,
|
|
103
|
+
gc_time: float = 300.0,
|
|
104
|
+
on_dispose: Callable[[Any], None] | None = None,
|
|
105
|
+
):
|
|
106
|
+
self.key = key
|
|
107
|
+
self.fn = fn
|
|
108
|
+
self.cfg = QueryConfig(
|
|
109
|
+
retries=retries,
|
|
110
|
+
retry_delay=retry_delay,
|
|
111
|
+
initial_data=initial_data,
|
|
112
|
+
gc_time=gc_time,
|
|
113
|
+
on_dispose=on_dispose,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Initialize reactive signals
|
|
117
|
+
self.data = Signal(
|
|
118
|
+
None if initial_data is MISSING else initial_data, name=f"query.data({key})"
|
|
119
|
+
)
|
|
120
|
+
self.error = Signal(None, name=f"query.error({key})")
|
|
121
|
+
self.last_updated = Signal(
|
|
122
|
+
time.time() if initial_data is not MISSING else 0.0,
|
|
123
|
+
name=f"query.last_updated({key})",
|
|
124
|
+
)
|
|
125
|
+
self.status = Signal(
|
|
126
|
+
"loading" if initial_data is MISSING else "success",
|
|
127
|
+
name=f"query.status({key})",
|
|
128
|
+
)
|
|
129
|
+
self.fetch_status = Signal("idle", name=f"query.fetch_status({key})")
|
|
130
|
+
self.retries = Signal(0, name=f"query.retries({key})")
|
|
131
|
+
self.retry_reason = Signal(None, name=f"query.retry_reason({key})")
|
|
132
|
+
|
|
133
|
+
self._observers = []
|
|
134
|
+
self._gc_handle = None
|
|
135
|
+
# Effect is created lazily on first observation
|
|
136
|
+
self._effect = None
|
|
137
|
+
|
|
138
|
+
def set_data(self, data: T):
|
|
139
|
+
self._set_success(data, manual=True)
|
|
140
|
+
|
|
141
|
+
def set_error(self, error: Exception):
|
|
142
|
+
self._set_error(error, manual=True)
|
|
143
|
+
|
|
144
|
+
def _set_success(self, data: T, manual: bool = False):
|
|
145
|
+
self.data.write(data)
|
|
146
|
+
self.last_updated.write(time.time())
|
|
147
|
+
self.error.write(None)
|
|
148
|
+
self.status.write("success")
|
|
149
|
+
if not manual:
|
|
150
|
+
self.fetch_status.write("idle")
|
|
151
|
+
self.retries.write(0)
|
|
152
|
+
self.retry_reason.write(None)
|
|
153
|
+
|
|
154
|
+
def _set_error(self, error: Exception, manual: bool = False):
|
|
155
|
+
self.error.write(error)
|
|
156
|
+
self.last_updated.write(time.time())
|
|
157
|
+
self.status.write("error")
|
|
158
|
+
if not manual:
|
|
159
|
+
self.fetch_status.write("idle")
|
|
160
|
+
# Don't reset retries on final error - preserve for debugging
|
|
161
|
+
# retry_reason is updated to the final error in _run
|
|
162
|
+
|
|
163
|
+
def _failed_retry(self, reason: Exception):
|
|
164
|
+
self.retries.write(self.retries.read() + 1)
|
|
165
|
+
self.retry_reason.write(reason)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def effect(self) -> AsyncEffect:
|
|
169
|
+
"""Lazy property that creates the query effect on first access."""
|
|
170
|
+
if self._effect is None:
|
|
171
|
+
self._effect = AsyncQueryEffect(
|
|
172
|
+
self._run,
|
|
173
|
+
query=self,
|
|
174
|
+
name=f"query_effect({self.key})",
|
|
175
|
+
deps=[] if self.key is not None else None,
|
|
176
|
+
)
|
|
177
|
+
return self._effect
|
|
178
|
+
|
|
179
|
+
async def _run(self):
|
|
180
|
+
# Reset retries at start of run
|
|
181
|
+
self.retries.write(0)
|
|
182
|
+
self.retry_reason.write(None)
|
|
183
|
+
|
|
184
|
+
while True:
|
|
185
|
+
try:
|
|
186
|
+
result = await self.fn()
|
|
187
|
+
self._set_success(result)
|
|
188
|
+
for obs in self._observers:
|
|
189
|
+
if obs._on_success: # pyright: ignore[reportPrivateUsage]
|
|
190
|
+
await maybe_await(call_flexible(obs._on_success, result)) # pyright: ignore[reportPrivateUsage]
|
|
191
|
+
return
|
|
192
|
+
except asyncio.CancelledError:
|
|
193
|
+
raise
|
|
194
|
+
except Exception as e:
|
|
195
|
+
current_retries = self.retries.read()
|
|
196
|
+
if current_retries < self.cfg.retries:
|
|
197
|
+
# Record failed retry attempt and retry
|
|
198
|
+
self._failed_retry(e)
|
|
199
|
+
# Wait before retrying
|
|
200
|
+
await asyncio.sleep(self.cfg.retry_delay)
|
|
201
|
+
else:
|
|
202
|
+
# All retries exhausted - update retry_reason to final error
|
|
203
|
+
self.retry_reason.write(e)
|
|
204
|
+
self._set_error(e)
|
|
205
|
+
for obs in self._observers:
|
|
206
|
+
if obs._on_error: # pyright: ignore[reportPrivateUsage]
|
|
207
|
+
await maybe_await(call_flexible(obs._on_error, e)) # pyright: ignore[reportPrivateUsage]
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
async def refetch(self, cancel_refetch: bool = True) -> T:
|
|
211
|
+
"""
|
|
212
|
+
Reruns the query and returns the result.
|
|
213
|
+
If cancel_refetch is True (default), cancels any in-flight request and starts a new one.
|
|
214
|
+
If cancel_refetch is False, deduplicates requests if one is already in flight.
|
|
215
|
+
"""
|
|
216
|
+
if cancel_refetch:
|
|
217
|
+
self.effect.cancel()
|
|
218
|
+
return await self.wait()
|
|
219
|
+
|
|
220
|
+
async def wait(self) -> T:
|
|
221
|
+
await self.effect.wait()
|
|
222
|
+
return cast(T, self.data.read())
|
|
223
|
+
|
|
224
|
+
def invalidate(self, cancel_refetch: bool = False):
|
|
225
|
+
"""
|
|
226
|
+
Marks query as stale. If there are active observers, triggers a refetch.
|
|
227
|
+
"""
|
|
228
|
+
should_schedule = not self.effect.is_scheduled or cancel_refetch
|
|
229
|
+
if should_schedule and len(self._observers) > 0:
|
|
230
|
+
self.effect.schedule()
|
|
231
|
+
|
|
232
|
+
def observe(
|
|
233
|
+
self,
|
|
234
|
+
observer: "QueryResult[T]",
|
|
235
|
+
):
|
|
236
|
+
_ = self.effect # ensure effect is created
|
|
237
|
+
self._observers.append(observer)
|
|
238
|
+
self.cancel_gc()
|
|
239
|
+
if observer._gc_time > 0: # pyright: ignore[reportPrivateUsage]
|
|
240
|
+
self.cfg.gc_time = max(self.cfg.gc_time, observer._gc_time) # pyright: ignore[reportPrivateUsage]
|
|
241
|
+
|
|
242
|
+
def unobserve(self, observer: "QueryResult[T]"):
|
|
243
|
+
"""Unregister an observer. Schedules GC if no observers remain."""
|
|
244
|
+
if observer in self._observers:
|
|
245
|
+
self._observers.remove(observer)
|
|
246
|
+
if len(self._observers) == 0:
|
|
247
|
+
self.schedule_gc()
|
|
248
|
+
|
|
249
|
+
def schedule_gc(self):
|
|
250
|
+
self.cancel_gc()
|
|
251
|
+
if self.cfg.gc_time > 0:
|
|
252
|
+
self._gc_handle = later(self.cfg.gc_time, self.dispose)
|
|
253
|
+
else:
|
|
254
|
+
self.dispose()
|
|
255
|
+
|
|
256
|
+
def cancel_gc(self):
|
|
257
|
+
if self._gc_handle:
|
|
258
|
+
self._gc_handle.cancel()
|
|
259
|
+
self._gc_handle = None
|
|
260
|
+
|
|
261
|
+
@override
|
|
262
|
+
def dispose(self):
|
|
263
|
+
"""
|
|
264
|
+
Cleans up the query entry, removing it from the store.
|
|
265
|
+
"""
|
|
266
|
+
if self._effect:
|
|
267
|
+
self._effect.dispose()
|
|
268
|
+
|
|
269
|
+
if self.cfg.on_dispose:
|
|
270
|
+
self.cfg.on_dispose(self)
|