From 8a6b6aa928c266d4af35a5c0ce496e6e12372f7e Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 14 May 2024 10:50:42 +0200 Subject: [PATCH] Improve FileDropper --- panel/config.py | 2 + panel/models/__init__.py | 1 - panel/models/file_dropper.py | 22 ++++++- panel/models/file_dropper.ts | 102 +++++++++++++++++++++++------- panel/widgets/input.py | 116 ++++++++++++++++++++++++++--------- 5 files changed, 190 insertions(+), 53 deletions(-) diff --git a/panel/config.py b/panel/config.py index 041ae07bedd..6744be1c91f 100644 --- a/panel/config.py +++ b/panel/config.py @@ -665,6 +665,7 @@ class panel_extension(_pyviz_extension): 'codeeditor': 'panel.models.ace', 'deckgl': 'panel.models.deckgl', 'echarts': 'panel.models.echarts', + 'filedropper': 'panel.models.file_dropper', 'ipywidgets': 'panel.io.ipywidget', 'jsoneditor': 'panel.models.jsoneditor', 'katex': 'panel.models.katex', @@ -685,6 +686,7 @@ class panel_extension(_pyviz_extension): _globals = { 'deckgl': ['deck'], 'echarts': ['echarts'], + 'filedropper': ['FilePond'], 'floatpanel': ['jsPanel'], 'gridstack': ['GridStack'], 'katex': ['katex'], diff --git a/panel/models/__init__.py b/panel/models/__init__.py index bae8fd6cd7b..5d388fcc929 100644 --- a/panel/models/__init__.py +++ b/panel/models/__init__.py @@ -8,7 +8,6 @@ from .browser import BrowserInfo # noqa from .datetime_picker import DatetimePicker # noqa from .feed import Feed # noqa -from .file_dropper import FileDropper # noqa from .icon import ButtonIcon, ToggleIcon, _ClickableIcon # noqa from .ipywidget import IPyWidget # noqa from .layout import Card, Column # noqa diff --git a/panel/models/file_dropper.py b/panel/models/file_dropper.py index f6fd6f6e785..6796821a1d7 100644 --- a/panel/models/file_dropper.py +++ b/panel/models/file_dropper.py @@ -1,8 +1,8 @@ from bokeh.core.properties import ( - Enum, Int, Nullable, String, + Bool, Dict, Enum, Int, List, Nullable, String, ) from bokeh.events import ModelEvent -from bokeh.models.widgets import FileInput +from bokeh.models.widgets import InputWidget from ..config import config from ..io.resources import bundled_files @@ -17,8 +17,18 @@ def __init__(self, model, data=None): self.data = data super().__init__(model=model) +class DeleteEvent(ModelEvent): -class FileDropper(FileInput): + event_name = 'delete_event' + + def __init__(self, model, data=None): + self.data = data + super().__init__(model=model) + + +class FileDropper(InputWidget): + + accepted_filetypes = List(String) chunk_size = Int(1000000) @@ -26,11 +36,17 @@ class FileDropper(FileInput): max_total_file_size = Nullable(String) + mime_type = Dict(String, String) + + multiple = Bool(True) + layout = Enum("integrated", "compact", "circle", default="compact") __javascript_raw__ = [ f"{config.npm_cdn}/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.js", f"{config.npm_cdn}/filepond-plugin-file-validate-size/dist/filepond-plugin-file-validate-size.js", + f"{config.npm_cdn}/filepond-plugin-file-validate-type/dist/filepond-plugin-file-validate-type.js", + f"{config.npm_cdn}/filepond-plugin-pdf-preview/dist/filepond-plugin-pdf-preview.min.js", f"{config.npm_cdn}/filepond@^4/dist/filepond.js" ] diff --git a/panel/models/file_dropper.ts b/panel/models/file_dropper.ts index 7e9be23173e..f6934c0f1d7 100644 --- a/panel/models/file_dropper.ts +++ b/panel/models/file_dropper.ts @@ -1,10 +1,12 @@ import {ModelEvent} from "@bokehjs/core/bokeh_events" import type {StyleSheetLike} from "@bokehjs/core/dom" +import {input} from "@bokehjs/core/dom" import {Enum} from "@bokehjs/core/kinds" import type * as p from "@bokehjs/core/properties" import type {Attrs} from "@bokehjs/core/types" -import {FileInput, FileInputView} from "@bokehjs/models/widgets/file_input" +import {InputWidget, InputWidgetView} from "@bokehjs/models/widgets/input_widget" +import * as inputs from "@bokehjs/styles/widgets/inputs.css" import filedropper_css from "styles/models/filedropper.css" export class UploadEvent extends ModelEvent { @@ -21,42 +23,92 @@ export class UploadEvent extends ModelEvent { } } -export class FileDropperView extends FileInputView { +export class DeleteEvent extends ModelEvent { + constructor(readonly data: any) { + super() + } + + protected override get event_values(): Attrs { + return {model: this.origin, data: this.data} + } + + static { + this.prototype.event_name = "delete_event" + } +} + +export class FileDropperView extends InputWidgetView { declare model: FileDropper declare input_el: HTMLInputElement + _file_pond: any | null = null _transfer_in_process: string | null = null override initialize(): void { super.initialize(); - (window as any).FilePond.registerPlugin((window as any).FilePondPluginImagePreview) + (window as any).FilePond.registerPlugin((window as any).FilePondPluginImagePreview); + (window as any).FilePond.registerPlugin((window as any).FilePondPluginPdfPreview); (window as any).FilePond.registerPlugin((window as any).FilePondPluginFileValidateSize) } + override connect_signals(): void { + super.connect_signals() + const {disabled, layout, max_file_size, max_total_file_size, multiple} = this.model.properties + this.on_change([disabled, max_file_size, max_total_file_size, multiple, layout], () => { + this._file_pond?.setOptions({ + acceptedFileTypes: this.model.accepted_filetypes, + allowMultiple: this.model.multiple, + disabled: this.model.disabled, + maxFileSize: this.model.max_file_size, + maxTotalFileSize: this.model.max_total_file_size, + stylePanelLayout: this.model.layout, + }) + }) + } + + override remove(): void { + if (this._file_pond) { + this._file_pond.destroy() + } + super.remove() + } + override stylesheets(): StyleSheetLike[] { - return [filedropper_css] + return [...super.stylesheets(), filedropper_css] + } + + protected _render_input(): HTMLInputElement { + const {multiple, disabled} = this.model + + return this.input_el = input({type: "file", class: inputs.input, multiple, disabled}) } override render(): void { super.render() - this.input_el.className = "filepond"; - (window as any).FilePond.create(this.input_el, { + this._file_pond = (window as any).FilePond.create(this.input_el, { + acceptedFileTypes: this.model.accepted_filetypes, allowMultiple: this.model.multiple, + credits: false, + disabled: this.model.disabled, maxFileSize: this.model.max_file_size, maxTotalFileSize: this.model.max_total_file_size, server: { process: ( - _: string, - file: File, - __: any, - load: (id: string) => void, - error: (msg: string) => void, - progress: (computable: boolean, loaded: number, total: number) => void, - ) => { - this._process_upload(file, load, error, progress) + _: string, + file: File, + __: any, + load: (id: string) => void, + error: (msg: string) => void, + progress: (computable: boolean, loaded: number, total: number) => void, + ) => { + this._process_upload(file, load, error, progress) }, - fetch: null, - revert: null, + fetch: null, + revert: (id: string, load: () => void): void => { + console.log(id) + this.model.trigger_event(new DeleteEvent({name: id})) + load() + } }, stylePanelLayout: this.model.layout, }) @@ -68,6 +120,7 @@ export class FileDropperView extends FileInputView { error: (msg: string) => void, progress: (computable: boolean, loaded: number, total: number) => void, ): Promise { + console.log(file) const buffer_size = this.model.chunk_size const chunks = Math.ceil(file.size / buffer_size) let abort_flag = false @@ -80,10 +133,11 @@ export class FileDropperView extends FileInputView { const start = i*buffer_size const end = Math.min(start+buffer_size, file.size) this.model.trigger_event(new UploadEvent({ - name: (file as any)._relativePath || file.name, chunk: i+1, - total_chunks: chunks, data: await file.slice(start, end).arrayBuffer(), + name: (file as any)._relativePath || file.name, + total_chunks: chunks, + type: file.type, })) progress(true, end, file.size) } @@ -101,17 +155,20 @@ export const DropperLayout = Enum("integrated", "compact", "circle") export namespace FileDropper { export type Attrs = p.AttrsOf - export type Props = FileInput.Props & { + export type Props = InputWidget.Props & { + accepted_filetypes: p.Property chunk_size: p.Property layout: p.Property max_file_size: p.Property max_total_file_size: p.Property + mime_type: p.Property + multiple: p.Property } } export interface FileDropper extends FileDropper.Attrs {} -export class FileDropper extends FileInput { +export class FileDropper extends InputWidget { declare properties: FileDropper.Props static override __module__ = "panel.models.file_dropper" @@ -122,10 +179,13 @@ export class FileDropper extends FileInput { static { this.prototype.default_view = FileDropperView - this.define(({Int, Nullable, Str}) => ({ + this.define(({Any, Array, Bool, Int, Nullable, Str}) => ({ + accepted_filetypes: [ Array(Str), [] ], chunk_size: [ Int, 1000000 ], max_file_size: [ Nullable(Str), null ], max_total_file_size: [ Nullable(Str), null ], + mime_type: [ Any, {} ], + multiple: [ Bool, true ], layout: [ DropperLayout, "compact" ], })) } diff --git a/panel/widgets/input.py b/panel/widgets/input.py index 8bbea19f7b3..def443acb85 100644 --- a/panel/widgets/input.py +++ b/panel/widgets/input.py @@ -24,14 +24,15 @@ PasswordInput as _BkPasswordInput, Spinner as _BkSpinner, Switch as _BkSwitch, ) +from pyviz_comms import JupyterComm from ..config import config from ..layout import Column, Panel from ..models import ( - DatetimePicker as _bkDatetimePicker, FileDropper as _BkFileDropper, - TextAreaInput as _bkTextAreaInput, TextInput as _BkTextInput, + DatetimePicker as _bkDatetimePicker, TextAreaInput as _bkTextAreaInput, + TextInput as _BkTextInput, ) -from ..util import param_reprs, try_datetime64_to_datetime +from ..util import lazy_load, param_reprs, try_datetime64_to_datetime from .base import CompositeWidget, Widget if TYPE_CHECKING: @@ -39,6 +40,7 @@ from bokeh.model import Model from pyviz_comms import Comm + from ..models.file_dropper import DeleteEvent, UploadEvent from ..viewable import Viewable @@ -193,20 +195,29 @@ class FileInput(Widget): >>> FileInput(accept='.png,.jpeg', multiple=True) """ - accept = param.String(default=None) + accept = param.String(default=None, doc=""" + A comma separated string of all extension types that should + be supported.""") description = param.String(default=None, doc=""" - An HTML string describing the function of this component.""") + An HTML string describing the function of this component + rendered as a tooltip icon.""") filename = param.ClassSelector( - default=None, class_=(str, list), is_instance=True) + default=None, class_=(str, list), is_instance=True, doc=""" + Name of the uploaded file(s).""") mime_type = param.ClassSelector( - default=None, class_=(str, list), is_instance=True) + default=None, class_=(str, list), is_instance=True, doc=""" + Mimetype of the uploaded file(s).""") - multiple = param.Boolean(default=False) + multiple = param.Boolean(default=False, doc=""" + Whether to allow uploading multiple files. If enabled value + parameter will return a list.""") - value = param.Parameter(default=None) + value = param.Parameter(default=None, doc=""" + The uploaded file(s) stored as a single bytes object if + multiple is False or a list of bytes otherwise.""") _rename: ClassVar[Mapping[str, str | None]] = { 'filename': None, 'name': None @@ -275,11 +286,38 @@ def save(self, filename): fn.write(val) -class FileDropper(FileInput): +class FileDropper(Widget): + """ + The `FileDropper` allows the user to upload one or more files to the server. + + It is similar to the `FileInput` widget but additionally adds support + for chunked uploads, making it possible to upload large files. The + UI also supports previews for image files. Unlike `FileInput` the + uploaded files are stored as dictionary of bytes object indexed + by the filename. + + Reference: https://panel.holoviz.org/reference/widgets/FileDropper.html + + :Example: + + >>> FileDropper(accepted_filetypes=['image/*'], multiple=True) + """ + + accepted_filetypes = param.List(default=[], doc=""" + List of accepted file types. Can be mime types or wild cards. + For instance ['image/*'] will accept all images. + ['image/png', 'image/jpeg'] will only accepts PNGs and JPEGs.""") chunk_size = param.Integer(default=1000000, doc=""" Size in bytes per chunk transferred across the WebSocket.""") + layout = param.Selector( + default="compact", objects=["circle", "compact", "integrated"], doc=""" + Compact mode will remove padding, integrated mode is used to render + FilePond as part of a bigger element. Circle mode adjusts the item + position offsets so buttons and progress indicators don't fall outside + of the circular shape.""") + max_file_size = param.String(default=None, doc=""" Maximum size of a file as a string with units given in KB or MB, e.g. 5MB or 750KB.""") @@ -288,18 +326,20 @@ class FileDropper(FileInput): Maximum size of all uploaded files, as a string with units given in KB or MB, e.g. 5MB or 750KB.""") - layout = param.Selector( - default="compact", objects=["circle", "compact", "integrated"], doc=""" - Compact mode will remove padding, integrated mode is used to render - FilePond as part of a bigger element. Circle mode adjusts the item - position offsets so buttons and progress indicators don't fall outside - of the circular shape.""") + mime_type = param.Dict(default={}, doc=""" + A dictionary containing the mimetypes for each of the uploaded + files indexed by their filename.""") - value = param.Dict(default={}) + multiple = param.Boolean(default=False, doc=""" + Whether to allow uploading multiple files. If enabled value + parameter will return a list.""") - _rename = {'value': None} + value = param.Dict(default={}, doc=""" + A dictionary containing the uploaded file(s) as bytes or string + objects indexed by the filename. Files that have a text/* mimetype + will automatically be decoded as utf-8.""") - _widget_type = _BkFileDropper + _rename = {'value': None} def __init__(self, **params): super().__init__(**params) @@ -309,20 +349,40 @@ def _get_model( self, doc: Document, root: Optional[Model] = None, parent: Optional[Model] = None, comm: Optional[Comm] = None ) -> Model: + self._widget_type = lazy_load( + 'panel.models.file_dropper', 'FileDropper', isinstance(comm, JupyterComm), root + ) model = super()._get_model(doc, root, parent, comm) - self._register_events('upload_event', model=model, doc=doc, comm=comm) + self._register_events('delete_event', 'upload_event', model=model, doc=doc, comm=comm) return model - def _process_event(self, event): - name = event.data['name'] - if event.data['chunk'] == 1: + def _process_event(self, event: DeleteEvent | UploadEvent): + data = event.data + name = data['name'] + if event.event_name == 'delete_event': + if name in self.mime_type: + del self.mime_type[name] + if name in self.value: + del self.value[name] + self.param.trigger('mime_type', 'value') + return + + if data['chunk'] == 1: self._file_buffer[name] = [] - self._file_buffer[name].append(event.data['data']) - if event.data['chunk'] == event.data['total_chunks']: - buffers = self._file_buffer[name] - self.value[name] = b''.join(buffers) - self.param.trigger('value') + self._file_buffer[name].append(data['data']) + if data['chunk'] != data['total_chunks']: + return + buffers = self._file_buffer.pop(name) + file_buffer = b''.join(buffers) + if data['type'].startswith('text/'): + try: + file_buffer = file_buffer.decode('utf-8') + except UnicodeDecodeError: + pass + self.value[name] = file_buffer + self.mime_type[name] = data['type'] + self.param.trigger('mime_type', 'value') class StaticText(Widget):