WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Commit 770766d

Browse files
committed
Add state serialization documentation and custom reducer feature
- Introduce a new `state_serialization.md` file detailing the state serialization process for livecomponents. - Document the `livecomponents_reducer` decorator to enforce custom pickling for dynamically created classes.
1 parent ae862c1 commit 770766d

File tree

6 files changed

+145
-17
lines changed

6 files changed

+145
-17
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## UNRELEASED
99

1010
- Updated livecomponent documentation. Added simple counter example.
11+
- Added and documented livecomponents_reducer decorator to force the use of a custom reducer for dynamically created classes.
1112

1213
## 1.15.0 (2025-04-08)
1314

docs/livecomponents.md

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -166,22 +166,6 @@ class Alert(LiveComponent):
166166

167167
Component states don't need to be stored if components are not expected to be re-rendered independently, and only as part of the parent component. For example, components for buttons are rarely re-rendered independently, so you can get away without the state model.
168168

169-
## Serializing Component State
170-
171-
When the page is rendered for the first time, a new session is created, and each component is initialized with its state by calling the `init_state()` method.
172-
173-
The state is then serialized and stored in the session store, and as long as the session is the same (in other words, while the page is not reloaded), the state is reused.
174-
175-
The state is serialized using the `StateSerializer` class and saved in Redis. By default, the `PickleStateSerializer` is used. The serializer uses a custom pickler and is optimized to effectively store the most common types of data used in a Django app. More specifically:
176-
177-
- When serializing a Django model, only the model's name and primary key are stored. The serializer takes advantage of the persistent_id/persistent_load pickle mechanism.
178-
- When serializing a Pydantic model, only the model's name and the values of the fields are stored.
179-
- When serializing a Django form, only the form's class name, as well as initial data and data, are stored.
180-
181-
!!! note "Session Storage Size Warning"
182-
183-
Livecomponents use Redis as the session store. Remember that a new session is created for each page load of every client, and stored there for 24 hours by default. This means you should keep the state small.
184-
185169
## Stateless components
186170

187171
If the component doesn't store any state, you can inherit from the StatelessLiveComponent class. You may find this helpful for rendering a hierarchy of components where the shared state is stored in the root components.

docs/state_serialization.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# State Serialization
2+
3+
When the page is rendered for the first time, a new session is created, and each component is initialized with its state by calling the `init_state()` method.
4+
5+
The state is then serialized and stored in the session store. As long as the session remains the same (in other words, while the page is not reloaded), the state is reused.
6+
7+
The state is serialized using the `StateSerializer` class and saved in Redis.
8+
9+
> [!NOTE]
10+
>
11+
> **Session Storage Size Warning.**
12+
> Livecomponents use Redis as the session store. Keep in mind that a new session is created for each page load for every client and is stored there for 24 hours by default. This means you should keep the state compact.
13+
14+
## PickleStateSerializer
15+
16+
To convert the object to a string, the `PickleStateSerializer` is used.
17+
18+
The serializer employs a custom pickler and is optimized to effectively store the most common types of data used in a Django app. More specifically:
19+
20+
- When serializing a Django model, only the model's name and primary key are stored. The serializer utilizes the persistent_id/persistent_load pickle mechanism.
21+
- When serializing a Pydantic model, only the model's name and the values of the fields are stored.
22+
- When serializing a Django form, only the form's class name, along with initial data and data, are stored.
23+
24+
## Serializing Dynamically Created Classes
25+
26+
> [!WARNING]
27+
> This section is an advanced topic that most users do not need. It's only necessary if you create model forms dynamically.
28+
29+
By default, pickle does not support serializing classes created dynamically (that is, classes defined as a result of a function call). Creating classes from functions is uncommon, but in some rare cases, you may want to do this, for example, when creating Django model forms dynamically.
30+
31+
If you attempt to serialize a dynamically created class, you will encounter an error like this:
32+
33+
```
34+
AttributeError: Can't pickle local object 'dynamic_user_form.<locals>.DynamicUserForm'
35+
```
36+
37+
The common solution for serializing dynamically created classes is to use a custom reducer by overriding the `__reduce__` method (see [Python docs](https://docs.python.org/3/library/pickle.html#object.__reduce__)). However, since `PickleStateSerializer` overrides the reducer for certain classes (specifically, for Django forms, templates, and models), your custom reducer will not be called for them.
38+
39+
If you want to ensure the use of your custom reducer, you must decorate it with the `livecomponents_reducer` decorator.
40+
41+
Here's the complete example:
42+
43+
```python
44+
from django.forms import ModelForm
45+
from django.contrib.auth.models import User
46+
47+
from livecomponents.manager.serializers import livecomponents_reducer
48+
49+
50+
def dynamic_user_form(form_fields: list[str]):
51+
"""Dynamically create a ModelForm for the User model with specified fields."""
52+
53+
class DynamicUserForm(ModelForm):
54+
class Meta:
55+
model = User
56+
fields = form_fields
57+
58+
@livecomponents_reducer # <--- This is the key!
59+
def __reduce__(self):
60+
data = self.data if self.is_bound else None
61+
constructor_kwargs = {
62+
"initial": self.initial,
63+
"data": data,
64+
}
65+
if hasattr(self, "instance"):
66+
constructor_kwargs["instance"] = self.instance
67+
return restore_dynamic_user_form, (form_fields, constructor_kwargs)
68+
69+
return DynamicUserForm
70+
71+
72+
def restore_dynamic_user_form(form_fields: list[str], constructor_kwargs: dict):
73+
"""Restore the dynamic User form with specified fields."""
74+
instance = dynamic_user_form(form_fields)(**constructor_kwargs)
75+
instance.full_clean()
76+
return instance
77+
```

livecomponents/manager/serializers.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import io
33
import pickle
44
import pickletools
5+
from collections.abc import Callable
56
from typing import Any
67

78
from django.apps import apps
@@ -50,6 +51,10 @@ class LivecomponentsPickler(pickle.Pickler):
5051
"""
5152

5253
def reducer_override(self, obj):
54+
if has_livecomponent_reducer(obj):
55+
logger.debug("Custom pickling: using custom reducer for %s", obj.__class__)
56+
return NotImplemented
57+
5358
if isinstance(obj, DjangoTemplates):
5459
return pickle_django_templates(obj)
5560
if isinstance(obj, BaseForm):
@@ -180,3 +185,23 @@ def pickle_django_model(instance: Model):
180185
def unpickle_django_model(cls, field_data: dict):
181186
logger.debug("Custom unpickling: Django model: class=%s", cls.__name__)
182187
return cls(**field_data)
188+
189+
190+
def livecomponents_reducer(func: Callable) -> Callable:
191+
"""Mark that the reducer as to be used for pickling with LivecomponentsPickler.
192+
193+
The decorator has to be applied to the `__reduce__` method of a class when we want
194+
to enforce its use even for the objects, for which LivecomponentsPickler
195+
defines a custom reducer.
196+
197+
See docs/state_serialization.md for more details.
198+
"""
199+
func._livecomponents_reducer = True # type: ignore
200+
return func
201+
202+
203+
def has_livecomponent_reducer(obj: Any) -> bool:
204+
"""Check if the object has a custom reducer for LivecomponentsPickler."""
205+
return hasattr(obj, "__reduce__") and getattr(
206+
obj.__reduce__, "_livecomponents_reducer", False
207+
)

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ nav:
3535
- Advanced:
3636
- context.md
3737
- templates.md
38+
- state_serialization.md
3839
- component_ids.md
3940
- About:
4041
- Changelog: https://github.com/om-proptech/livecomponents/blob/main/CHANGELOG.md

tests/test_serializers.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
from django.forms import ModelForm
99
from pydantic import BaseModel
1010

11-
from livecomponents.manager.serializers import PickleStateSerializer
11+
from livecomponents.manager.serializers import (
12+
PickleStateSerializer,
13+
livecomponents_reducer,
14+
)
1215

1316

1417
class MyModel(BaseModel):
@@ -29,6 +32,35 @@ class Meta:
2932
fields = ["username", "email"]
3033

3134

35+
def dynamic_user_form(form_fields: list[str]):
36+
"""Dynamically create a ModelForm for the User model with specified fields."""
37+
38+
class DynamicUserForm(ModelForm):
39+
class Meta:
40+
model = User
41+
fields = form_fields
42+
43+
@livecomponents_reducer
44+
def __reduce__(self):
45+
data = self.data if self.is_bound else None
46+
constructor_kwargs = {
47+
"initial": self.initial,
48+
"data": data,
49+
}
50+
if hasattr(self, "instance"):
51+
constructor_kwargs["instance"] = self.instance
52+
return restore_dynamic_user_form, (form_fields, constructor_kwargs)
53+
54+
return DynamicUserForm
55+
56+
57+
def restore_dynamic_user_form(form_fields: list[str], constructor_kwargs: dict):
58+
"""Restore the dynamic User form with specified fields."""
59+
instance = dynamic_user_form(form_fields)(**constructor_kwargs)
60+
instance.full_clean()
61+
return instance
62+
63+
3264
@pytest.mark.parametrize(
3365
"obj, expected_arg",
3466
[
@@ -127,6 +159,14 @@ def test_model_form_serialization(admin_user):
127159
assert deserialized.instance == admin_user
128160

129161

162+
@pytest.mark.django_db
163+
def test_dynamic_model_with_custom_reducer_serialization(admin_user):
164+
form = dynamic_user_form(form_fields=["username", "email"])(instance=admin_user)
165+
deserialized = reserialize(form)
166+
assert deserialized.fields.keys() == {"username", "email"}
167+
assert deserialized.instance == admin_user
168+
169+
130170
def test_model_form_serialization_unsaved_instance():
131171
form = MyModelForm()
132172
form.instance.username = "foo"

0 commit comments

Comments
 (0)