Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: multiple file support for file_drop #562

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/solara-enterprise/solara_enterprise/ssg.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def _get_playwright():

pw.browser = pw.sync_playwright.chromium.launch(headless=not settings.ssg.headed)
pw.page = pw.browser.new_page()
playwrights.append(pw)
return pw


Expand Down Expand Up @@ -81,12 +82,13 @@ def wait(async_result):

for result in results:
wait(result)
thread_pool.terminate()
thread_pool.close()
thread_pool.join()
for pw in playwrights:
assert pw.browser is not None
assert pw.context_manager is not None
pw.browser.close()
pw.context_manager.stop()
pw.context_manager.__exit__(None, None, None)

rprint("Done building SSG")

Expand Down
2 changes: 1 addition & 1 deletion solara/components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
from .figure_altair import FigureAltair, AltairChart # noqa: #F401 F403
from .meta import Meta # noqa: #F401 F403
from .columns import Columns, ColumnsResponsive # noqa: #F401 F403
from .file_drop import FileDrop # noqa: #F401 F403
from .file_drop import FileDrop, FileDropMultiple # noqa: #F401 F403
from .file_download import FileDownload # noqa: #F401 F403
from .tooltip import Tooltip # noqa: #F401 F403
from .card import Card, CardActions # noqa: #F401 F403
Expand Down
109 changes: 76 additions & 33 deletions solara/components/file_drop.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import threading
import typing
from typing import Callable, Optional, cast
from typing import Callable, List, Optional, Union, cast

import traitlets
from ipyvue import Template
Expand All @@ -25,45 +25,26 @@ class FileDropZone(FileInput):
template_file = (__file__, "file_drop.vue")
items = traitlets.List(default_value=[]).tag(sync=True)
label = traitlets.Unicode().tag(sync=True)
multiple = traitlets.Bool(True).tag(sync=True)


@solara.component
def FileDrop(
label="Drop file here",
def _FileDrop(
label="Drop file(s) here",
on_total_progress: Optional[Callable[[float], None]] = None,
on_file: Optional[Callable[[FileInfo], None]] = None,
on_file: Optional[Callable[[Union[FileInfo, List[FileInfo]]], None]] = None,
lazy: bool = True,
multiple: bool = False,
):
"""Region a user can drop a file into for file uploading.

If lazy=True, no file content will be loaded into memory,
nor will any data be transferred by default.
A file object is passed to the `on_file` callback, and data will be transferred
when needed.

If lazy=False, the file content will be loaded into memory and passed to the `on_file` callback via the `.data` attribute.

The on_file callback takes the following argument type:
```python
class FileInfo(typing.TypedDict):
name: str # file name
size: int # file size in bytes
file_obj: typing.BinaryIO
data: Optional[bytes]: bytes # only present if lazy=False
```


## Arguments
* `on_total_progress`: Will be called with the progress in % of the file upload.
* `on_file`: Will be called with a `FileInfo` object, which contains the file `.name`, `.length` and a `.file_obj` object.
* `lazy`: Whether to load the file content into memory or not. If `False`,
the file content will be loaded into memory and passed to the `on_file` callback via the `.data` attribute.
"""Generic implementation used by FileDrop and FileDropMultiple.

If multiple=True, multiple files can be uploaded.
"""

file_info, set_file_info = solara.use_state(None)
wired_files, set_wired_files = solara.use_state(cast(Optional[typing.List[FileInfo]], None))

file_drop = FileDropZone.element(label=label, on_total_progress=on_total_progress, on_file_info=set_file_info) # type: ignore
file_drop = FileDropZone.element(label=label, on_total_progress=on_total_progress, on_file_info=set_file_info, multiple=multiple) # type: ignore

def wire_files():
if not file_info:
Expand All @@ -83,14 +64,76 @@ def handle_file(cancel: threading.Event):
if not wired_files:
return
if on_file:
if not lazy:
wired_files[0]["data"] = wired_files[0]["file_obj"].read()
for i in range(len(wired_files)):
if not lazy:
wired_files[i]["data"] = wired_files[i]["file_obj"].read()
else:
wired_files[i]["data"] = None
if multiple:
on_file(wired_files)
else:
wired_files[0]["data"] = None
on_file(wired_files[0])
on_file(wired_files[0])

result: solara.Result = hooks.use_thread(handle_file, [wired_files])
if result.error:
raise result.error

return file_drop


@solara.component
def FileDrop(
label="Drop file here",
on_total_progress: Optional[Callable[[float], None]] = None,
on_file: Optional[Callable[[FileInfo], None]] = None,
lazy: bool = True,
):
"""Region a user can drop a file into for file uploading.

If lazy=True, no file content will be loaded into memory,
nor will any data be transferred by default.
If lazy=False, file content will be loaded into memory and passed to the `on_file` callback via the `FileInfo.data` attribute.


A file object is of the following argument type:
```python
class FileInfo(typing.TypedDict):
name: str # file name
size: int # file size in bytes
file_obj: typing.BinaryIO
data: Optional[bytes]: bytes # only present if lazy=False
```


## Arguments
* `on_total_progress`: Will be called with the progress in % of the file upload.
* `on_file`: Will be called with a `FileInfo` object, which contains the file `.name`, `.length` and a `.file_obj` object.
* `lazy`: Whether to load the file contents into memory or not. If `False`,
the file contents will be loaded into memory via the `.data` attribute of file object(s).

"""

return _FileDrop(label=label, on_total_progress=on_total_progress, on_file=on_file, lazy=lazy, multiple=False)


@solara.component
def FileDropMultiple(
label="Drop files here",
on_total_progress: Optional[Callable[[float], None]] = None,
on_file: Optional[Callable[[List[FileInfo]], None]] = None,
lazy: bool = True,
):
"""Region a user can drop multiple files into for file uploading.

Almost identical to `FileDrop` except that multiple files can be dropped and `on_file` is called
with a list of `FileInfo` objects.

## Arguments
* `on_total_progress`: Will be called with the progress in % of the file(s) upload.
* `on_file`: Will be called with a `List[FileInfo]`.
Each `FileInfo` contains the file `.name`, `.length`, `.file_obj` object, and `.data` attributes.
* `lazy`: Whether to load the file contents into memory or not.

"""

return _FileDrop(label=label, on_total_progress=on_total_progress, on_file=on_file, lazy=lazy, multiple=True)
34 changes: 24 additions & 10 deletions solara/components/file_drop.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
<template>
<div ref="dropzone" class="solara-file-drop" effectAllowed="move">
{{ (file_info && file_info.length === 1) ? file_info[0].name : label }}
<template v-if="file_info && file_info.length > 0">
<template v-if="multiple">
<div v-for="file in file_info">
{{ file.name }}
</div>
</template>
<template v-else>
{{ file_info[0].name }}
</template>
</template>
<template v-else>
{{ label }}
</template>
</div>
</template>

Expand All @@ -16,16 +28,17 @@ module.exports = {
event.preventDefault();
const items = await Promise.all([...event.dataTransfer.items]);
const files = items.map(i => i.webkitGetAsEntry())
const fileHolder = files.filter(f => f.isFile)[0]
const file = await new Promise((rs, rj) => fileHolder.file(rs, rj))
const fileHolders = files.filter(f => f.isFile)
const nativeFilesPromises = fileHolders.map(fileHolder => new Promise((rs, rj) => fileHolder.file(rs, rj)))
const nativeFiles = await Promise.all(nativeFilesPromises)

this.native_file_info = [file]
this.native_file_info = nativeFiles
this.file_info = this.native_file_info.map(
({name, isFile, size}) => ({
name,
isFile,
size,
}));
({name, size}) => ({
name,
isFile: true,
size,
}));
});
},
methods: {
Expand Down Expand Up @@ -64,6 +77,7 @@ module.exports = {
height: 100px;
border: 1px dashed gray;
margin: 8px 0;
padding: 8px
padding: 8px;
overflow: auto;
}
</style>
73 changes: 56 additions & 17 deletions solara/website/pages/documentation/components/input/file_drop.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,75 @@
"""
# FileDrop
# FileDrop components

FileDrop comes in two flavours:

* `FileDrop` for a single file upload
* `FileDropMultiple` which allows for multiple file upload


"""
import textwrap
from typing import List, cast

import solara
from solara.components.file_drop import FileInfo
from solara.website.utils import apidoc


@solara.component
def Page():
def FileDropMultipleDemo():
content, set_content = solara.use_state(cast(List[bytes], []))
filename, set_filename = solara.use_state(cast(List[str], []))
size, set_size = solara.use_state(cast(List[int], []))

def on_file(files: List[FileInfo]):
set_filename([f["name"] for f in files])
set_size([f["size"] for f in files])
set_content([f["file_obj"].read(100) for f in files])

solara.FileDropMultiple(
label="Drag and drop files(s) here to read the first 100 bytes.",
on_file=on_file,
lazy=True, # We will only read the first 100 bytes
)
if content:
solara.Info(f"Number of uploaded files: {len(filename)}")
for f, s, c in zip(filename, size, content):
solara.Info(f"File {f} has total length: {s}\n, first 100 bytes:")
solara.Preformatted("\n".join(textwrap.wrap(repr(c))))


@solara.component
def FileDropDemo():
content, set_content = solara.use_state(b"")
filename, set_filename = solara.use_state("")
size, set_size = solara.use_state(0)

def on_file(file: FileInfo):
set_filename(file["name"])
set_size(file["size"])
f = file["file_obj"]
set_content(f.read(100))
def on_file(f: FileInfo):
set_filename(f["name"])
set_size(f["size"])
set_content(f["file_obj"].read(100))

with solara.Div() as main:
solara.FileDrop(
label="Drag and drop a file here to read the first 100 bytes",
on_file=on_file,
lazy=True, # We will only read the first 100 bytes
)
if content:
solara.Info(f"File {filename} has total length: {size}\n, first 100 bytes:")
solara.Preformatted("\n".join(textwrap.wrap(repr(content))))
solara.FileDrop(
label="Drag and drop a file here to read the first 100 bytes.",
on_file=on_file,
lazy=True, # We will only read the first 100 bytes
)
if content:
solara.Info(f"File {filename} has total length: {size}\n, first 100 bytes:")
solara.Preformatted("\n".join(textwrap.wrap(repr(content))))

return main

@solara.component
def Page():
with solara.Row():
with solara.Card(title="FileDrop"):
FileDropDemo()
with solara.Card(title="FileDropMultiple"):
FileDropMultipleDemo()


__doc__ += "# FileDrop"
__doc__ += apidoc(solara.FileDrop.f) # type: ignore
__doc__ += "# FileDropMultiple"
__doc__ += apidoc(solara.FileDropMultiple.f) # type: ignore
Loading