Skip to content
Open
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
11 changes: 8 additions & 3 deletions solara/components/file_drop.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def _FileDrop(
on_file: Optional[Callable[[Union[FileInfo, List[FileInfo]]], None]] = None,
lazy: bool = True,
multiple: bool = False,
accept: Optional[str] = None,
):
"""Generic implementation used by FileDrop and FileDropMultiple.

Expand All @@ -44,7 +45,7 @@ def _FileDrop(
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, multiple=multiple) # type: ignore
file_drop = FileDropZone.element(label=label, on_total_progress=on_total_progress, on_file_info=set_file_info, multiple=multiple, accept=accept or "") # type: ignore

def wire_files():
if not file_info:
Expand Down Expand Up @@ -87,6 +88,7 @@ def FileDrop(
on_total_progress: Optional[Callable[[float], None]] = None,
on_file: Optional[Callable[[FileInfo], None]] = None,
lazy: bool = True,
accept: Optional[str] = None,
):
"""Region a user can drop a file into for file uploading.

Expand All @@ -110,6 +112,7 @@ class FileInfo(typing.TypedDict):
* `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).
* `accept`: Comma-separated file extensions or MIME types to accept, for example `.csv,image/*`.

## Load into Pandas
To load the data into a Pandas DF, set `lazy=False` and use `file['file_obj']` (be careful of memory)<br>
Expand All @@ -133,7 +136,7 @@ def load_file_df(file):

"""

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


@solara.component
Expand All @@ -142,6 +145,7 @@ def FileDropMultiple(
on_total_progress: Optional[Callable[[float], None]] = None,
on_file: Optional[Callable[[List[FileInfo]], None]] = None,
lazy: bool = True,
accept: Optional[str] = None,
):
"""Region a user can drop multiple files into for file uploading.

Expand All @@ -153,7 +157,8 @@ def FileDropMultiple(
* `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.
* `accept`: Comma-separated file extensions or MIME types to accept, for example `.csv,image/*`.

"""

return _FileDrop(label=label, on_total_progress=on_total_progress, on_file=on_file, lazy=lazy, multiple=True)
return _FileDrop(label=label, on_total_progress=on_total_progress, on_file=on_file, lazy=lazy, multiple=True, accept=accept)
19 changes: 18 additions & 1 deletion solara/components/file_drop.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ module.exports = {
const files = items.map(i => i.webkitGetAsEntry())
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)
const nativeFiles = (await Promise.all(nativeFilesPromises)).filter(this.fileIsAccepted)

this.native_file_info = nativeFiles
this.file_info = this.native_file_info.map(
Expand All @@ -42,6 +42,23 @@ module.exports = {
});
},
methods: {
fileIsAccepted(file) {
if (!this.accept) {
return true;
}
const name = file.name.toLowerCase();
const type = file.type.toLowerCase();
return this.accept.split(',').some(value => {
const token = value.trim().toLowerCase();
if (token.startsWith('.')) {
return name.endsWith(token);
}
if (token.endsWith('/*')) {
return type.startsWith(token.slice(0, -1));
}
return type === token;
});
},
jupyter_clear() {
this.native_file_info = [];
this.file_info = [];
Expand Down
53 changes: 53 additions & 0 deletions tests/integration/file_drop_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import playwright.sync_api
import pytest

import solara


@pytest.mark.parametrize(
("accept", "multiple", "files", "expected"),
[
(".csv", False, [("data.csv", "text/plain"), ("data.txt", "text/csv")], "data.csv"),
("text/csv", False, [("data.txt", "text/csv"), ("data.csv", "text/plain")], "data.txt"),
("image/*", False, [("photo.png", "image/png"), ("data.csv", "text/csv")], "photo.png"),
(None, False, [("data.bin", "application/octet-stream")], "data.bin"),
(".csv, image/*", True, [("data.csv", "text/plain"), ("notes.txt", "text/plain"), ("photo.png", "image/png")], "data.csv,photo.png"),
],
)
def test_file_drop_accept(
solara_test,
page_session: playwright.sync_api.Page,
accept,
multiple: bool,
files,
expected: str,
):
@solara.component
def Page():
received = solara.use_reactive("none")

if multiple:
solara.FileDropMultiple(accept=accept, on_file=lambda value: received.set(",".join(file["name"] for file in value)))
else:
solara.FileDrop(accept=accept, on_file=lambda value: received.set(value["name"]))
solara.Text(received.value, classes=["received-files"])

solara.display(Page())
page_session.locator(".solara-file-drop").evaluate(
"""(element, files) => {
const event = new Event('drop', {bubbles: true});
Object.defineProperty(event, 'dataTransfer', {
value: {
items: files.map(([name, type]) => ({
webkitGetAsEntry: () => ({
isFile: true,
file: resolve => resolve(new File(['content'], name, {type})),
}),
})),
},
});
element.dispatchEvent(event);
}""",
files,
)
playwright.sync_api.expect(page_session.locator(".received-files")).to_have_text(expected)