Skip to content

Commit

Permalink
Make UploadedFile class serializable (#679)
Browse files Browse the repository at this point in the history
* Make UploadedFile class serializable

Since we're inheriting from io.Bytes, this made it harder to serialize
since we couldn't just make it a data class.

So to workaround this we added custom encoder/decoder logic to handle
UploadedFile specifically.

We also needed to add a custom operator for DeepDiff. For whatever
reason deep diff wasn't diffing it correctly, so instead we just perform
a basical equality check here.

Demos/tests have been updated to illustrate that UploadedFile is now
serializable.
  • Loading branch information
richard-to committed Jul 29, 2024
1 parent 9f94610 commit a2681d5
Show file tree
Hide file tree
Showing 12 changed files with 282 additions and 66 deletions.
26 changes: 13 additions & 13 deletions demo/uploader.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@

@me.stateclass
class State:
name: str
size: int
mime_type: str
contents: str
file: me.UploadedFile


@me.page(
Expand All @@ -29,19 +26,22 @@ def app():
style=me.Style(font_weight="bold"),
)

if state.contents:
if state.file.size:
with me.box(style=me.Style(margin=me.Margin.all(10))):
me.text(f"File name: {state.name}")
me.text(f"File size: {state.size}")
me.text(f"File type: {state.mime_type}")
me.text(f"File name: {state.file.name}")
me.text(f"File size: {state.file.size}")
me.text(f"File type: {state.file.mime_type}")

with me.box(style=me.Style(margin=me.Margin.all(10))):
me.image(src=state.contents)
me.image(src=_convert_contents_data_url(state.file))


def handle_upload(event: me.UploadEvent):
state = me.state(State)
state.name = event.file.name
state.size = event.file.size
state.mime_type = event.file.mime_type
state.contents = f"data:{event.file.mime_type};base64,{base64.b64encode(event.file.getvalue()).decode()}"
state.file = event.file


def _convert_contents_data_url(file: me.UploadedFile) -> str:
return (
f"data:{file.mime_type};base64,{base64.b64encode(file.getvalue()).decode()}"
)
6 changes: 6 additions & 0 deletions mesop/components/uploader/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,16 @@ package(
mesop_component(
name = "uploader",
assets = [":uploader.css"],
py_deps = [":uploaded_file"],
)

sass_binary(
name = "styles",
src = "uploader.scss",
sourcemap = False,
)

py_library(
name = "uploaded_file",
srcs = ["uploaded_file.py"],
)
48 changes: 26 additions & 22 deletions mesop/components/uploader/e2e/uploader_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,38 @@

@me.stateclass
class State:
name: str
size: int
mime_type: str
contents: str
file: me.UploadedFile


@me.page(path="/components/uploader/e2e/uploader_app")
def app():
state = me.state(State)
me.uploader(
label="Upload Image",
accepted_file_types=["image/jpeg", "image/png"],
on_upload=handle_upload,
)

if state.contents:
with me.box(style=me.Style(margin=me.Margin.all(10))):
me.text(f"File name: {state.name}")
me.text(f"File size: {state.size}")
me.text(f"File type: {state.mime_type}")

with me.box(style=me.Style(margin=me.Margin.all(10))):
me.image(src=state.contents)
with me.box(style=me.Style(padding=me.Padding.all(15))):
me.uploader(
label="Upload Image",
accepted_file_types=["image/jpeg", "image/png"],
on_upload=handle_upload,
type="flat",
color="primary",
style=me.Style(font_weight="bold"),
)

if state.file.size:
with me.box(style=me.Style(margin=me.Margin.all(10))):
me.text(f"File name: {state.file.name}")
me.text(f"File size: {state.file.size}")
me.text(f"File type: {state.file.mime_type}")

with me.box(style=me.Style(margin=me.Margin.all(10))):
me.image(src=_convert_contents_data_url(state.file))


def handle_upload(event: me.UploadEvent):
state = me.state(State)
state.name = event.file.name
state.size = event.file.size
state.mime_type = event.file.mime_type
state.contents = f"data:{event.file.mime_type};base64,{base64.b64encode(event.file.getvalue()).decode()}"
state.file = event.file


def _convert_contents_data_url(file: me.UploadedFile) -> str:
return (
f"data:{file.mime_type};base64,{base64.b64encode(file.getvalue()).decode()}"
)
4 changes: 3 additions & 1 deletion mesop/components/uploader/e2e/uploader_test.ts

Large diffs are not rendered by default.

42 changes: 42 additions & 0 deletions mesop/components/uploader/uploaded_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import io


# Store this class in a separate file so we can more easily reference
# in dataclass utils.
class UploadedFile(io.BytesIO):
"""Uploaded file contents and metadata."""

def __init__(
self,
contents: bytes = b"",
*,
name: str = "",
size: int = 0,
mime_type: str = "",
):
super().__init__(contents)
self._name = name
self._size = size
self._mime_type = mime_type

@property
def name(self):
return self._name

@property
def size(self):
return self._size

@property
def mime_type(self):
return self._mime_type

def __eq__(self, other):
if isinstance(other, UploadedFile):
return (self.getvalue(), self.name, self.size, self.mime_type) == (
other.getvalue(),
other.name,
other.size,
other.mime_type,
)
return False
24 changes: 1 addition & 23 deletions mesop/components/uploader/uploader.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import io
from dataclasses import dataclass
from typing import Any, Callable, Literal, Sequence

Expand All @@ -10,32 +9,11 @@
register_event_mapper,
register_native_component,
)
from mesop.components.uploader.uploaded_file import UploadedFile
from mesop.events import MesopEvent
from mesop.exceptions import MesopDeveloperException


class UploadedFile(io.BytesIO):
"""Uploaded file contents and metadata."""

def __init__(self, contents: bytes, *, name: str, size: int, mime_type: str):
super().__init__(contents)
self._name = name
self._size = size
self._mime_type = mime_type

@property
def name(self):
return self._name

@property
def size(self):
return self._size

@property
def mime_type(self):
return self._mime_type


@dataclass(kw_only=True)
class UploadEvent(MesopEvent):
"""Event for file uploads.
Expand Down
15 changes: 12 additions & 3 deletions mesop/dataclass_utils/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,26 @@ py_library(
["*.py"],
exclude = ["*_test.py"],
),
deps = ["//mesop/exceptions"] + THIRD_PARTY_PY_DEEPDIFF,
deps = [
"//mesop/components/uploader:uploaded_file",
"//mesop/exceptions",
] + THIRD_PARTY_PY_DEEPDIFF,
)

py_test(
name = "dataclass_utils_test",
srcs = ["dataclass_utils_test.py"],
deps = [":dataclass_utils"] + THIRD_PARTY_PY_PYTEST + THIRD_PARTY_PY_PANDAS,
deps = [
":dataclass_utils",
"//mesop/components/uploader:uploaded_file",
] + THIRD_PARTY_PY_PYTEST + THIRD_PARTY_PY_PANDAS,
)

py_test(
name = "diff_state_test",
srcs = ["diff_state_test.py"],
deps = [":dataclass_utils"] + THIRD_PARTY_PY_PYTEST + THIRD_PARTY_PY_PANDAS,
deps = [
":dataclass_utils",
"//mesop/components/uploader:uploaded_file",
] + THIRD_PARTY_PY_PYTEST + THIRD_PARTY_PY_PANDAS,
)
64 changes: 61 additions & 3 deletions mesop/dataclass_utils/dataclass_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@
from deepdiff.operator import BaseOperator
from deepdiff.path import parse_path

from mesop.components.uploader.uploaded_file import UploadedFile
from mesop.exceptions import MesopDeveloperException, MesopException

_PANDAS_OBJECT_KEY = "__pandas.DataFrame__"
_DATETIME_OBJECT_KEY = "__datetime.datetime__"
_BYTES_OBJECT_KEY = "__python.bytes__"
_UPLOADED_FILE_OBJECT_KEY = "__mesop.UploadedFile__"
_DIFF_ACTION_DATA_FRAME_CHANGED = "data_frame_changed"
_DIFF_ACTION_UPLOADED_FILE_CHANGED = "mesop_uploaded_file_changed"

C = TypeVar("C")

Expand Down Expand Up @@ -174,8 +177,19 @@ def default(self, obj):
except ImportError:
pass

if isinstance(obj, UploadedFile):
return {
_UPLOADED_FILE_OBJECT_KEY: {
"contents": base64.b64encode(obj.getvalue()).decode("utf-8"),
"name": obj.name,
"size": obj.size,
"mime_type": obj.mime_type,
}
}

if isinstance(obj, datetime):
return {_DATETIME_OBJECT_KEY: obj.isoformat()}

if isinstance(obj, bytes):
return {_BYTES_OBJECT_KEY: base64.b64encode(obj).decode("utf-8")}

Expand Down Expand Up @@ -210,6 +224,14 @@ def decode_mesop_json_state_hook(dct):
if _BYTES_OBJECT_KEY in dct:
return base64.b64decode(dct[_BYTES_OBJECT_KEY])

if _UPLOADED_FILE_OBJECT_KEY in dct:
return UploadedFile(
base64.b64decode(dct[_UPLOADED_FILE_OBJECT_KEY]["contents"]),
name=dct[_UPLOADED_FILE_OBJECT_KEY]["name"],
size=dct[_UPLOADED_FILE_OBJECT_KEY]["size"],
mime_type=dct[_UPLOADED_FILE_OBJECT_KEY]["mime_type"],
)

return dct


Expand Down Expand Up @@ -241,6 +263,29 @@ def give_up_diffing(self, level, diff_instance) -> bool:
return True


class UploadedFileOperator(BaseOperator):
"""Custom operator to detect changes in UploadedFile class.
DeepDiff does not diff the UploadedFile class correctly, so we will just use a normal
equality check, rather than diffing further into the io.BytesIO parent class.
This class could probably be made more generic to handle other classes where we want
to diff using equality checks.
"""

def match(self, level) -> bool:
return isinstance(level.t1, UploadedFile) and isinstance(
level.t2, UploadedFile
)

def give_up_diffing(self, level, diff_instance) -> bool:
if level.t1 != level.t2:
diff_instance.custom_report_result(
_DIFF_ACTION_UPLOADED_FILE_CHANGED, level, {"value": level.t2}
)
return True


def diff_state(state1: Any, state2: Any) -> str:
"""
Diffs two state objects and returns the difference using DeepDiff's Delta format as a
Expand All @@ -255,11 +300,13 @@ def diff_state(state1: Any, state2: Any) -> str:
raise MesopException("Tried to diff state which was not a dataclass")

custom_actions = []

custom_operators = [UploadedFileOperator()]
# Only use the `DataFrameOperator` if pandas exists.
if _has_pandas:
differences = DeepDiff(
state1, state2, custom_operators=[DataFrameOperator()]
state1,
state2,
custom_operators=[*custom_operators, DataFrameOperator()],
)

# Manually format dataframe diffs to flat dict format.
Expand All @@ -273,7 +320,18 @@ def diff_state(state1: Any, state2: Any) -> str:
for path, diff in differences[_DIFF_ACTION_DATA_FRAME_CHANGED].items()
]
else:
differences = DeepDiff(state1, state2)
differences = DeepDiff(state1, state2, custom_operators=custom_operators)

# Manually format UploadedFile diffs to flat dict format.
if _DIFF_ACTION_UPLOADED_FILE_CHANGED in differences:
custom_actions = [
{
"path": parse_path(path),
"action": _DIFF_ACTION_UPLOADED_FILE_CHANGED,
**diff,
}
for path, diff in differences[_DIFF_ACTION_UPLOADED_FILE_CHANGED].items()
]

return json.dumps(
Delta(differences, always_include_values=True).to_flat_dicts()
Expand Down
Loading

0 comments on commit a2681d5

Please sign in to comment.