From a62d70b150c4916d8b1a9f99a6cf52cb0ecc52ae Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 13 May 2024 12:51:52 +0200 Subject: [PATCH 01/14] Add FileDropper widget --- panel/models/__init__.py | 1 + panel/models/file_dropper.py | 41 +++++++++++++ panel/models/file_dropper.ts | 111 +++++++++++++++++++++++++++++++++++ panel/models/index.ts | 2 +- panel/widgets/__init__.py | 5 +- panel/widgets/input.py | 46 ++++++++++++++- 6 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 panel/models/file_dropper.py create mode 100644 panel/models/file_dropper.ts diff --git a/panel/models/__init__.py b/panel/models/__init__.py index 5d388fcc92..bae8fd6cd7 100644 --- a/panel/models/__init__.py +++ b/panel/models/__init__.py @@ -8,6 +8,7 @@ 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 new file mode 100644 index 0000000000..d14497793d --- /dev/null +++ b/panel/models/file_dropper.py @@ -0,0 +1,41 @@ +from bokeh.core.properties import Enum, Int +from bokeh.events import ModelEvent +from bokeh.models.widgets import FileInput + +from ..config import config +from ..io.resources import bundled_files +from ..util import classproperty + + +class UploadEvent(ModelEvent): + + event_name = 'upload_event' + + def __init__(self, model, data=None): + self.data = data + super().__init__(model=model) + + +class FileDropper(FileInput): + + chunk_size = Int(1000000) + + 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@^4/dist/filepond.js" + ] + + @classproperty + def __javascript__(cls): + return bundled_files(cls) + + __css_raw__ = [ + f"{config.npm_cdn}/filepond@^4/dist/filepond.css", + f"{config.npm_cdn}/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css" + ] + + @classproperty + def __css__(cls): + return bundled_files(cls, 'css') diff --git a/panel/models/file_dropper.ts b/panel/models/file_dropper.ts new file mode 100644 index 0000000000..b31396c9c5 --- /dev/null +++ b/panel/models/file_dropper.ts @@ -0,0 +1,111 @@ +import {ModelEvent} from "@bokehjs/core/bokeh_events" +import type {StyleSheetLike} 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 filedropper_css from "styles/models/filedropper.css" + +export class UploadEvent 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 = "upload_event" + } +} + +export class FileDropperView extends FileInputView { + declare model: FileDropper + declare input_el: HTMLInputElement + _transfer_in_process: string | null = null + + override initialize(): void { + super.initialize(); + (window as any).FilePond.registerPlugin((window as any).FilePondPluginImagePreview) + } + + override stylesheets(): StyleSheetLike[] { + return [filedropper_css] + } + + override render(): void { + super.render() + + this.input_el.className = "filepond"; + (window as any).FilePond.create(this.input_el, { + allowMultiple: this.model.multiple, + stylePanelLayout: this.model.layout, + server: { + process: (fieldName: string, file: File, metadata, load, error, progress, abort) => this._process_upload(fieldName, file, metadata, load, error, progress, abort), + fetch: null, + revert: null, + }, + }) + } + + async private _process_upload(fieldName: string, file: File, metadata, load, error, progress, abort): any { + const buffer_size = this.model.chunk_size + const chunks = Math.ceil(file.size / buffer_size) + let abort_flag = false + new Promise(async (resolve, reject) => { + for (let i = 0; i < chunks; i++) { + if (abort_flag) { + reject(file.name) + return + } + 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(), + })) + progress(true, end, file.size) + } + load(file.name) + resolve(file.name) + }).catch(() => error()) + + return {abort: () => { + abort_flag = true + }} + } +} + +export const DropperLayout = Enum("integrated", "compact", "circle") + +export namespace FileDropper { + export type Attrs = p.AttrsOf + export type Props = FileInput.Props & { + chunk_size: p.Property + layout: p.Property + } +} + +export interface FileDropper extends FileDropper.Attrs {} + +export class FileDropper extends FileInput { + declare properties: FileDropper.Props + + static override __module__ = "panel.models.file_dropper" + + constructor(attrs?: Partial) { + super(attrs) + } + + static { + this.prototype.default_view = FileDropperView + this.define(({Int}) => ({ + chunk_size: [ Int, 1000000], + layout: [DropperLayout, "compact" ], + })) + } +} diff --git a/panel/models/index.ts b/panel/models/index.ts index 8a7d444996..070c485ccc 100644 --- a/panel/models/index.ts +++ b/panel/models/index.ts @@ -16,6 +16,7 @@ export {DeckGLPlot} from "./deckgl" export {ECharts} from "./echarts" export {Feed} from "./feed" export {FileDownload} from "./file_download" +export {FileDropper} from "./file_dropper" export {HTML} from "./html" export {IPyWidget} from "./ipywidget" export {JSON} from "./json" @@ -37,7 +38,6 @@ export {State} from "./state" export {Tabs} from "./tabs" export {Terminal} from "./terminal" export {TextAreaInput} from "./textarea_input" -export {TextInput} from "./text_input" export {TextToSpeech} from "./text_to_speech" export {ToggleIcon} from "./toggle_icon" export {TooltipIcon} from "./tooltip_icon" diff --git a/panel/widgets/__init__.py b/panel/widgets/__init__.py index c69f38061c..cc2d8e05b8 100644 --- a/panel/widgets/__init__.py +++ b/panel/widgets/__init__.py @@ -45,8 +45,8 @@ from .input import ( # noqa ArrayInput, Checkbox, ColorPicker, DatePicker, DateRangePicker, DatetimeInput, DatetimePicker, DatetimeRangeInput, DatetimeRangePicker, - FileInput, FloatInput, IntInput, LiteralInput, NumberInput, PasswordInput, - Spinner, StaticText, Switch, TextAreaInput, TextInput, + FileDroppr, FileInput, FloatInput, IntInput, LiteralInput, NumberInput, + PasswordInput, Spinner, StaticText, Switch, TextAreaInput, TextInput, ) from .misc import FileDownload, JSONEditor, VideoStream # noqa from .player import DiscretePlayer, Player # noqa @@ -98,6 +98,7 @@ "EditableIntSlider", "EditableRangeSlider", "FileDownload", + "FileDropper", "FileInput", "FileSelector", "FloatInput", diff --git a/panel/widgets/input.py b/panel/widgets/input.py index 9e50994704..7d803983c2 100644 --- a/panel/widgets/input.py +++ b/panel/widgets/input.py @@ -28,8 +28,8 @@ from ..config import config from ..layout import Column, Panel from ..models import ( - DatetimePicker as _bkDatetimePicker, TextAreaInput as _bkTextAreaInput, - TextInput as _BkTextInput, + DatetimePicker as _bkDatetimePicker, FileDropper as _BkFileDropper, + TextAreaInput as _bkTextAreaInput, TextInput as _BkTextInput, ) from ..util import param_reprs, try_datetime64_to_datetime from .base import CompositeWidget, Widget @@ -275,6 +275,48 @@ def save(self, filename): fn.write(val) +class FileDropper(FileInput): + + 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.""") + + value = param.Dict(default={}) + + _rename = {'value': None} + + _widget_type = _BkFileDropper + + def __init__(self, **params): + super().__init__(**params) + self._file_buffer = {} + + def _get_model( + self, doc: Document, root: Optional[Model] = None, + parent: Optional[Model] = None, comm: Optional[Comm] = None + ) -> Model: + model = super()._get_model(doc, root, parent, comm) + self._register_events('upload_event', model=model, doc=doc, comm=comm) + return model + + def _process_event(self, event): + name = event.data['name'] + if event.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') + + + class StaticText(Widget): """ The `StaticText` widget displays a text value, but does not allow editing From 62a9166c8af485d4d42a021c5dad7fbed9e91267 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 13 May 2024 17:19:15 +0200 Subject: [PATCH 02/14] Various fixes --- panel/models/file_dropper.py | 9 ++++++++- panel/models/file_dropper.ts | 37 ++++++++++++++++++++++++++++-------- panel/widgets/__init__.py | 2 +- panel/widgets/input.py | 8 ++++++++ 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/panel/models/file_dropper.py b/panel/models/file_dropper.py index d14497793d..f6fd6f6e78 100644 --- a/panel/models/file_dropper.py +++ b/panel/models/file_dropper.py @@ -1,4 +1,6 @@ -from bokeh.core.properties import Enum, Int +from bokeh.core.properties import ( + Enum, Int, Nullable, String, +) from bokeh.events import ModelEvent from bokeh.models.widgets import FileInput @@ -20,10 +22,15 @@ class FileDropper(FileInput): chunk_size = Int(1000000) + max_file_size = Nullable(String) + + max_total_file_size = Nullable(String) + 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@^4/dist/filepond.js" ] diff --git a/panel/models/file_dropper.ts b/panel/models/file_dropper.ts index b31396c9c5..7e9be23173 100644 --- a/panel/models/file_dropper.ts +++ b/panel/models/file_dropper.ts @@ -29,6 +29,7 @@ export class FileDropperView extends FileInputView { override initialize(): void { super.initialize(); (window as any).FilePond.registerPlugin((window as any).FilePondPluginImagePreview) + (window as any).FilePond.registerPlugin((window as any).FilePondPluginFileValidateSize) } override stylesheets(): StyleSheetLike[] { @@ -41,16 +42,32 @@ export class FileDropperView extends FileInputView { this.input_el.className = "filepond"; (window as any).FilePond.create(this.input_el, { allowMultiple: this.model.multiple, - stylePanelLayout: this.model.layout, + maxFileSize: this.model.max_file_size, + maxTotalFileSize: this.model.max_total_file_size, server: { - process: (fieldName: string, file: File, metadata, load, error, progress, abort) => this._process_upload(fieldName, file, metadata, load, error, progress, abort), - fetch: null, + 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) + }, + fetch: null, revert: null, }, + stylePanelLayout: this.model.layout, }) } - async private _process_upload(fieldName: string, file: File, metadata, load, error, progress, abort): any { + private async _process_upload( + file: File, + load: (id: string) => void, + error: (msg: string) => void, + progress: (computable: boolean, loaded: number, total: number) => void, + ): Promise { const buffer_size = this.model.chunk_size const chunks = Math.ceil(file.size / buffer_size) let abort_flag = false @@ -72,7 +89,7 @@ export class FileDropperView extends FileInputView { } load(file.name) resolve(file.name) - }).catch(() => error()) + }).catch(() => error('Upload failed.')) return {abort: () => { abort_flag = true @@ -87,6 +104,8 @@ export namespace FileDropper { export type Props = FileInput.Props & { chunk_size: p.Property layout: p.Property + max_file_size: p.Property + max_total_file_size: p.Property } } @@ -103,9 +122,11 @@ export class FileDropper extends FileInput { static { this.prototype.default_view = FileDropperView - this.define(({Int}) => ({ - chunk_size: [ Int, 1000000], - layout: [DropperLayout, "compact" ], + this.define(({Int, Nullable, Str}) => ({ + chunk_size: [ Int, 1000000 ], + max_file_size: [ Nullable(Str), null ], + max_total_file_size: [ Nullable(Str), null ], + layout: [ DropperLayout, "compact" ], })) } } diff --git a/panel/widgets/__init__.py b/panel/widgets/__init__.py index cc2d8e05b8..ee8b249c4f 100644 --- a/panel/widgets/__init__.py +++ b/panel/widgets/__init__.py @@ -45,7 +45,7 @@ from .input import ( # noqa ArrayInput, Checkbox, ColorPicker, DatePicker, DateRangePicker, DatetimeInput, DatetimePicker, DatetimeRangeInput, DatetimeRangePicker, - FileDroppr, FileInput, FloatInput, IntInput, LiteralInput, NumberInput, + FileDropper, FileInput, FloatInput, IntInput, LiteralInput, NumberInput, PasswordInput, Spinner, StaticText, Switch, TextAreaInput, TextInput, ) from .misc import FileDownload, JSONEditor, VideoStream # noqa diff --git a/panel/widgets/input.py b/panel/widgets/input.py index 7d803983c2..8bbea19f7b 100644 --- a/panel/widgets/input.py +++ b/panel/widgets/input.py @@ -280,6 +280,14 @@ class FileDropper(FileInput): chunk_size = param.Integer(default=1000000, doc=""" Size in bytes per chunk transferred across the WebSocket.""") + 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.""") + + max_total_file_size = param.String(default=None, doc=""" + 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 From ed4908fc22081d90635fb45ed7193aede9113b82 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 14 May 2024 10:50:42 +0200 Subject: [PATCH 03/14] 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 ea1f23f70f..d81296428d 100644 --- a/panel/config.py +++ b/panel/config.py @@ -663,6 +663,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', @@ -683,6 +684,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 bae8fd6cd7..5d388fcc92 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 f6fd6f6e78..6796821a1d 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 7e9be23173..f6934c0f1d 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 8bbea19f7b..def443acb8 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): From 6342e8f0d2875e6057c830852bafbeb7f6b4dc8f Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 14 May 2024 12:51:34 +0200 Subject: [PATCH 04/14] Fix styling --- panel/models/file_dropper.py | 9 ++++++--- panel/models/file_dropper.ts | 14 +++++++++----- panel/styles/models/filedropper.less | 4 ++++ panel/widgets/input.py | 7 +++++-- 4 files changed, 24 insertions(+), 10 deletions(-) create mode 100644 panel/styles/models/filedropper.less diff --git a/panel/models/file_dropper.py b/panel/models/file_dropper.py index 6796821a1d..7757fa3abc 100644 --- a/panel/models/file_dropper.py +++ b/panel/models/file_dropper.py @@ -30,7 +30,9 @@ class FileDropper(InputWidget): accepted_filetypes = List(String) - chunk_size = Int(1000000) + chunk_size = Int(10_000_000) + + max_files = Nullable(Int) max_file_size = Nullable(String) @@ -40,7 +42,7 @@ class FileDropper(InputWidget): multiple = Bool(True) - layout = Enum("integrated", "compact", "circle", default="compact") + layout = Nullable(Enum("integrated", "compact", "circle", default="compact")) __javascript_raw__ = [ f"{config.npm_cdn}/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.js", @@ -56,7 +58,8 @@ def __javascript__(cls): __css_raw__ = [ f"{config.npm_cdn}/filepond@^4/dist/filepond.css", - f"{config.npm_cdn}/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css" + f"{config.npm_cdn}/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css", + f"{config.npm_cdn}/filepond-plugin-pdf-preview/dist/filepond-plugin-pdf-preview.css" ] @classproperty diff --git a/panel/models/file_dropper.ts b/panel/models/file_dropper.ts index f6934c0f1d..025816e441 100644 --- a/panel/models/file_dropper.ts +++ b/panel/models/file_dropper.ts @@ -52,12 +52,13 @@ export class FileDropperView extends InputWidgetView { 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], () => { + const {disabled, layout, max_file_size, max_files, max_total_file_size, multiple} = this.model.properties + this.on_change([disabled, max_file_size, max_files, max_total_file_size, multiple, layout], () => { this._file_pond?.setOptions({ acceptedFileTypes: this.model.accepted_filetypes, allowMultiple: this.model.multiple, disabled: this.model.disabled, + maxFiles: this.model.max_files, maxFileSize: this.model.max_file_size, maxTotalFileSize: this.model.max_total_file_size, stylePanelLayout: this.model.layout, @@ -90,6 +91,7 @@ export class FileDropperView extends InputWidgetView { allowMultiple: this.model.multiple, credits: false, disabled: this.model.disabled, + maxFiles: this.model.max_files, maxFileSize: this.model.max_file_size, maxTotalFileSize: this.model.max_total_file_size, server: { @@ -158,8 +160,9 @@ export namespace FileDropper { export type Props = InputWidget.Props & { accepted_filetypes: p.Property chunk_size: p.Property - layout: p.Property + layout: p.Property max_file_size: p.Property + max_files: p.Property max_total_file_size: p.Property mime_type: p.Property multiple: p.Property @@ -181,12 +184,13 @@ export class FileDropper extends InputWidget { this.prototype.default_view = FileDropperView this.define(({Any, Array, Bool, Int, Nullable, Str}) => ({ accepted_filetypes: [ Array(Str), [] ], - chunk_size: [ Int, 1000000 ], + chunk_size: [ Int, 10000000 ], max_file_size: [ Nullable(Str), null ], + max_files: [ Nullable(Int), null ], max_total_file_size: [ Nullable(Str), null ], mime_type: [ Any, {} ], multiple: [ Bool, true ], - layout: [ DropperLayout, "compact" ], + layout: [ Nullable(DropperLayout), null ], })) } } diff --git a/panel/styles/models/filedropper.less b/panel/styles/models/filedropper.less new file mode 100644 index 0000000000..60b03ab885 --- /dev/null +++ b/panel/styles/models/filedropper.less @@ -0,0 +1,4 @@ +.bk-input.filepond--root { + background-color: unset; + border: unset; +} diff --git a/panel/widgets/input.py b/panel/widgets/input.py index def443acb8..683d0719ad 100644 --- a/panel/widgets/input.py +++ b/panel/widgets/input.py @@ -308,11 +308,11 @@ class FileDropper(Widget): For instance ['image/*'] will accept all images. ['image/png', 'image/jpeg'] will only accepts PNGs and JPEGs.""") - chunk_size = param.Integer(default=1000000, doc=""" + chunk_size = param.Integer(default=10_000_000, doc=""" Size in bytes per chunk transferred across the WebSocket.""") layout = param.Selector( - default="compact", objects=["circle", "compact", "integrated"], doc=""" + default=None, 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 @@ -322,6 +322,9 @@ class FileDropper(Widget): Maximum size of a file as a string with units given in KB or MB, e.g. 5MB or 750KB.""") + max_files = param.String(default=None, doc=""" + Maximum number of files that can be uploaded if multiple=True.""") + max_total_file_size = param.String(default=None, doc=""" Maximum size of all uploaded files, as a string with units given in KB or MB, e.g. 5MB or 750KB.""") From 4c1446a97cb06dcdad1e9c54fe65b9c1ec47914e Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 14 May 2024 13:20:12 +0200 Subject: [PATCH 05/14] Add reference notebook --- examples/reference/widgets/FileDropper.ipynb | 176 +++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 examples/reference/widgets/FileDropper.ipynb diff --git a/examples/reference/widgets/FileDropper.ipynb b/examples/reference/widgets/FileDropper.ipynb new file mode 100644 index 0000000000..bc81e31ef6 --- /dev/null +++ b/examples/reference/widgets/FileDropper.ipynb @@ -0,0 +1,176 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import io\n", + "import panel as pn\n", + "pn.extension('filedropper')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `FileDropper` allows the user to upload one or more files to the server. It is built on top of the [FilePond](https://pqina.nl/filepond/) library, if you use this component extensively consider donating to them. The `FileDropper` 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.\n", + "\n", + "Discover more on using widgets to add interactivity to your applications in the [how-to guides on interactivity](../how_to/interactivity/index.md). Alternatively, learn [how to set up callbacks and (JS-)links between parameters](../../how_to/links/index.md) or [how to use them as part of declarative UIs with Param](../../how_to/param/index.html).\n", + "\n", + "#### Parameters:\n", + "\n", + "For details on other options for customizing the component see the [layout](../../how_to/layout/index.md) and [styling](../../how_to/styling/index.md) how-to guides.\n", + "\n", + "##### Core\n", + "\n", + "* **`accepted_filetypes`** (list): List of accepted file types. Can be mimetypes, file extensions or wild cards. For instance `['image/*']` will accept all images while `['.png', 'image/jpeg']` will only accepts PNGs and JPEGs.\n", + "* **`chunk_size`** (int): Size in bytes per chunk transferred across the WebSocket (`default=10000000`, i.e. 10MB).\n", + "* **`layout`** (Literal[\"circle\", \"compact\", \"integrated\"] | None): Compact mode will remove padding, integrated mode is used to render FilePond as part of a bigger element (and should not be used with `multiple=True`. Circle mode adjusts the item position offsets so buttons and progress indicators don't fall outside of the circular shape.\n", + "* **`max_file_size`** (str): Maximum size of a file as a string with units given in KB or MB, e.g. 5MB or 750KB.\n", + "* **`max_files`** (int): Maximum number of files that can be uploaded if `multiple=True`.\n", + "* **`max_total_file_size`** (str): Maximum size of all uploaded files, as a string with units given in KB or MB, e.g. 5MB or 750KB.\n", + "* **`mime_type`** (dict[str, str]): A dictionary containing the mimetypes for each of the uploaded files indexed by their filename.\n", + "* **`multiple`** (bool): Whether to allow uploading multiple files.\n", + "* **`value`** (dict[str, str | bytes]): 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`.\n", + "\n", + "___" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "file_dropper = pn.widgets.FileDropper()\n", + "\n", + "file_dropper" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Try uploading an image or PDF file and you will see a preview of the uploaded file.\n", + "\n", + "To read out the content of the file you can access the ``value`` parameter, which holds a dictionary mapping from the filename to a string or [bytestring](https://docs.python.org/3/library/stdtypes.html#bytes-objects) containing the file's contents. Any filetype that declares a `text/*` mimetype will automatically be decoded into a string. The mimetype itself is made available on the `mime_type` parameter expressed as a MIME type, e.g. `image/png` or `text/csv`, again expressed as a dictionary mapping from filename to filetype." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "file_dropper.value" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Filetypes\n", + "\n", + "The `accepted_filetypes` parameter restricts what files the user can pick from. This consists of a list of mimetypes that also allows wildcards. Values can be:\n", + "\n", + "* `` - Specific file extension(s) (e.g: .gif, .jpg, .png, .doc) are pickable\n", + "* `audio/*` - all sound files are pickable\n", + "* `video/*` - all video files are pickable\n", + "* `image/*` - all image files are pickable\n", + "* `` - A valid [IANA Media Type](https://www.iana.org/assignments/media-types/media-types.xhtml), with no parameters." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "file_dropper = pn.widgets.FileDropper(accepted_filetypes=['.png', 'image/jpeg'])\n", + "\n", + "file_dropper" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To allow uploading multiple files we can also set `multiple=True`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "file_dropper = pn.widgets.FileInput(multiple=True)\n", + "\n", + "file_dropper" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Layout\n", + "\n", + "The `FileDropper` allows for a few different layout options:\n", + "\n", + "- `\"compact\"`: Remove margins.\n", + "- `\"integrated\"`: Removes background and other styling. Useful when the component is embedded inside a larger component.\n", + "- `\"circle\"`: Circular upload area useful for profile picture uploads." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pn.Row(\n", + " pn.widgets.FileDropper(layout=\"compact\"),\n", + " pn.widgets.FileDropper(layout=\"integrated\", styles={'background-color': 'black', 'border-radius': '1em', 'color': 'white'}),\n", + " pn.widgets.FileDropper(layout=\"circle\"),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Upload size limits\n", + "\n", + "Unlike the `FileInput` widget the `FileDropper` widget bypasses restrictions to the maximum file size imposed by web browsers, Bokeh, Tornado, notebooks, etc. by chunking large uploads. This makes it feasible to upload much larger files than would otherwise be possible. The default `chunk_size` is 10MB (which is expressed in as 10000000 bytes). Even if it is possible to increase this limit by setting some parameters (described below), bear in mind that the `FileInput` widget is not meant to upload large files. You can configure `max_file_size`, `max_total_file_size` (limiting the total upload size if you have set `multiple=True`) and `max_files` to provide an upper bound on the amount of data that can be uploaded." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Controls\n", + "\n", + "The `FileInput` widget exposes a number of options which can be changed from both Python and Javascript. Try out the effect of these parameters interactively:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pn.Row(file_dropper.controls(jslink=True), file_input)" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} From 36e3ef83af67a37e105901282a28d63a81c7e4f4 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 14 May 2024 13:20:45 +0200 Subject: [PATCH 06/14] Small fixes/updates --- panel/models/file_dropper.ts | 1 + panel/widgets/input.py | 15 +++++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/panel/models/file_dropper.ts b/panel/models/file_dropper.ts index 025816e441..f483d89960 100644 --- a/panel/models/file_dropper.ts +++ b/panel/models/file_dropper.ts @@ -47,6 +47,7 @@ export class FileDropperView extends InputWidgetView { super.initialize(); (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).FilePondPluginFileValidateType); (window as any).FilePond.registerPlugin((window as any).FilePondPluginFileValidateSize) } diff --git a/panel/widgets/input.py b/panel/widgets/input.py index 683d0719ad..ad9e9d55b8 100644 --- a/panel/widgets/input.py +++ b/panel/widgets/input.py @@ -304,9 +304,9 @@ class FileDropper(Widget): """ 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.""") + List of accepted file types. Can be mime types, file extensions + or wild cards.For instance ['image/*'] will accept all images. + ['.png', 'image/jpeg'] will only accepts PNGs and JPEGs.""") chunk_size = param.Integer(default=10_000_000, doc=""" Size in bytes per chunk transferred across the WebSocket.""") @@ -322,7 +322,7 @@ class FileDropper(Widget): Maximum size of a file as a string with units given in KB or MB, e.g. 5MB or 750KB.""") - max_files = param.String(default=None, doc=""" + max_files = param.Integer(default=None, doc=""" Maximum number of files that can be uploaded if multiple=True.""") max_total_file_size = param.String(default=None, doc=""" @@ -334,14 +334,17 @@ class FileDropper(Widget): files indexed by their filename.""") multiple = param.Boolean(default=False, doc=""" - Whether to allow uploading multiple files. If enabled value - parameter will return a list.""") + Whether to allow uploading multiple files.""") 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.""") + width = param.Integer(default=300, allow_None=True, doc=""" + Width of this component. If sizing_mode is set to stretch + or scale mode this will merely be used as a suggestion.""") + _rename = {'value': None} def __init__(self, **params): From 45892d11043f861349d10826a1bfcb3f686f2963 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 14 May 2024 13:23:33 +0200 Subject: [PATCH 07/14] Fix lint --- panel/models/file_dropper.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/panel/models/file_dropper.ts b/panel/models/file_dropper.ts index f483d89960..4d0797b416 100644 --- a/panel/models/file_dropper.ts +++ b/panel/models/file_dropper.ts @@ -59,7 +59,7 @@ export class FileDropperView extends InputWidgetView { acceptedFileTypes: this.model.accepted_filetypes, allowMultiple: this.model.multiple, disabled: this.model.disabled, - maxFiles: this.model.max_files, + maxFiles: this.model.max_files, maxFileSize: this.model.max_file_size, maxTotalFileSize: this.model.max_total_file_size, stylePanelLayout: this.model.layout, @@ -108,10 +108,9 @@ export class FileDropperView extends InputWidgetView { }, fetch: null, revert: (id: string, load: () => void): void => { - console.log(id) - this.model.trigger_event(new DeleteEvent({name: id})) - load() - } + this.model.trigger_event(new DeleteEvent({name: id})) + load() + }, }, stylePanelLayout: this.model.layout, }) @@ -140,13 +139,13 @@ export class FileDropperView extends InputWidgetView { data: await file.slice(start, end).arrayBuffer(), name: (file as any)._relativePath || file.name, total_chunks: chunks, - type: file.type, + type: file.type, })) progress(true, end, file.size) } load(file.name) resolve(file.name) - }).catch(() => error('Upload failed.')) + }).catch(() => error("Upload failed.")) return {abort: () => { abort_flag = true From 6457e79dbb621fd8ab6e51653a2c121eb92a837b Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 14 May 2024 13:37:51 +0200 Subject: [PATCH 08/14] Small example notebook fix --- examples/reference/widgets/FileDropper.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/reference/widgets/FileDropper.ipynb b/examples/reference/widgets/FileDropper.ipynb index bc81e31ef6..c076ece760 100644 --- a/examples/reference/widgets/FileDropper.ipynb +++ b/examples/reference/widgets/FileDropper.ipynb @@ -161,7 +161,7 @@ "metadata": {}, "outputs": [], "source": [ - "pn.Row(file_dropper.controls(jslink=True), file_input)" + "pn.Row(file_dropper.controls(jslink=True), file_dropper)" ] } ], From 613410baf49a3f7083bfce8e81202381e3178335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Tue, 21 May 2024 14:04:21 +0200 Subject: [PATCH 09/14] Add back text_input --- panel/models/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/panel/models/index.ts b/panel/models/index.ts index 41d9857f07..4e448b9005 100644 --- a/panel/models/index.ts +++ b/panel/models/index.ts @@ -39,6 +39,7 @@ export {State} from "./state" export {Tabs} from "./tabs" export {Terminal} from "./terminal" export {TextAreaInput} from "./textarea_input" +export {TextInput} from "./text_input" export {TextToSpeech} from "./text_to_speech" export {ToggleIcon} from "./toggle_icon" export {TooltipIcon} from "./tooltip_icon" From 8c65091cec0c66410c68664b90542e46cb0304d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Tue, 21 May 2024 15:53:28 +0200 Subject: [PATCH 10/14] Fix lint and deprecations --- panel/models/file_dropper.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/panel/models/file_dropper.ts b/panel/models/file_dropper.ts index 4d0797b416..1fec50a6cd 100644 --- a/panel/models/file_dropper.ts +++ b/panel/models/file_dropper.ts @@ -104,7 +104,7 @@ export class FileDropperView extends InputWidgetView { error: (msg: string) => void, progress: (computable: boolean, loaded: number, total: number) => void, ) => { - this._process_upload(file, load, error, progress) + void this._process_upload(file, load, error, progress) }, fetch: null, revert: (id: string, load: () => void): void => { @@ -182,8 +182,8 @@ export class FileDropper extends InputWidget { static { this.prototype.default_view = FileDropperView - this.define(({Any, Array, Bool, Int, Nullable, Str}) => ({ - accepted_filetypes: [ Array(Str), [] ], + this.define(({Any, List, Bool, Int, Nullable, Str}) => ({ + accepted_filetypes: [ List(Str), [] ], chunk_size: [ Int, 10000000 ], max_file_size: [ Nullable(Str), null ], max_files: [ Nullable(Int), null ], From 3e979c0f9fe7175218c83a3c2fa30624b404d3f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Wed, 22 May 2024 15:42:17 +0200 Subject: [PATCH 11/14] Add tests --- panel/models/file_dropper.ts | 3 +-- panel/tests/ui/widgets/test_input.py | 37 +++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/panel/models/file_dropper.ts b/panel/models/file_dropper.ts index 1fec50a6cd..1ba10ddd2c 100644 --- a/panel/models/file_dropper.ts +++ b/panel/models/file_dropper.ts @@ -122,9 +122,8 @@ export class FileDropperView extends InputWidgetView { 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) + const chunks = Math.ceil((file.size + 1)/ buffer_size) // +1 is for empty files let abort_flag = false new Promise(async (resolve, reject) => { for (let i = 0; i < chunks; i++) { diff --git a/panel/tests/ui/widgets/test_input.py b/panel/tests/ui/widgets/test_input.py index 4177133957..8626c13f12 100644 --- a/panel/tests/ui/widgets/test_input.py +++ b/panel/tests/ui/widgets/test_input.py @@ -1,5 +1,7 @@ import datetime +from pathlib import Path + import numpy as np import pytest @@ -7,7 +9,7 @@ pytest.importorskip("playwright") -from playwright.sync_api import expect +from playwright.sync_api import Error, expect from panel.tests.util import serve_component, wait_until from panel.widgets import ( @@ -721,3 +723,36 @@ def on_enter(event): input_area.press("Enter") wait_until(lambda: clicks[0] == 2) assert text_input.value == "H" + +def test_filedropper_text_file(page): + widget = pn.widgets.FileDropper() + serve_component(page, widget) + + file = Path(__file__) + + page.set_input_files('input[type="file"]', file) + + wait_until(lambda: len(widget.value) == 1, page) + assert widget.value == {file.name: file.read_text()} + +def test_filedropper_multiple_file_error(page): + widget = pn.widgets.FileDropper() + serve_component(page, widget) + + msg = "Non-multiple file input can only accept single file" + with pytest.raises(Error, match=msg): + page.set_input_files('input[type="file"]', [__file__, __file__]) + +def test_filedropper_multiple_files(page): + widget = pn.widgets.FileDropper(multiple=True) + serve_component(page, widget) + + file1 = Path(__file__) + file2 = file1.parent / '__init__.py' + + page.set_input_files('input[type="file"]', [file1, file2]) + + wait_until(lambda: len(widget.value) == 2) + assert widget.value == { + file1.name: file1.read_text(), file2.name: file2.read_text() + } From 2fca2e32b5126f65490ab6b12ea883e7e6713bba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Wed, 22 May 2024 16:12:54 +0200 Subject: [PATCH 12/14] Update line-endings for Windows --- panel/tests/ui/widgets/test_input.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/panel/tests/ui/widgets/test_input.py b/panel/tests/ui/widgets/test_input.py index 8626c13f12..ecf243cb69 100644 --- a/panel/tests/ui/widgets/test_input.py +++ b/panel/tests/ui/widgets/test_input.py @@ -733,7 +733,9 @@ def test_filedropper_text_file(page): page.set_input_files('input[type="file"]', file) wait_until(lambda: len(widget.value) == 1, page) - assert widget.value == {file.name: file.read_text()} + assert widget.value == { + file.name: file.read_text().replace("\r\n", "\n"), + } def test_filedropper_multiple_file_error(page): widget = pn.widgets.FileDropper() @@ -754,5 +756,6 @@ def test_filedropper_multiple_files(page): wait_until(lambda: len(widget.value) == 2) assert widget.value == { - file1.name: file1.read_text(), file2.name: file2.read_text() + file1.name: file1.read_text().replace("\r\n", "\n"), + file2.name: file2.read_text().replace("\r\n", "\n"), } From 953de7d40214db19f1a1fa4e3f80d53b28a839ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Wed, 22 May 2024 17:40:48 +0200 Subject: [PATCH 13/14] Try again... --- panel/tests/ui/widgets/test_input.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/panel/tests/ui/widgets/test_input.py b/panel/tests/ui/widgets/test_input.py index ecf243cb69..5cc79e4e07 100644 --- a/panel/tests/ui/widgets/test_input.py +++ b/panel/tests/ui/widgets/test_input.py @@ -1,4 +1,5 @@ import datetime +import sys from pathlib import Path @@ -733,9 +734,10 @@ def test_filedropper_text_file(page): page.set_input_files('input[type="file"]', file) wait_until(lambda: len(widget.value) == 1, page) - assert widget.value == { - file.name: file.read_text().replace("\r\n", "\n"), - } + data = file.read_text() + if sys.platform == 'win32': + data = data.replace("\n", "\r\n") + assert widget.value == {file.name: data} def test_filedropper_multiple_file_error(page): widget = pn.widgets.FileDropper() @@ -753,9 +755,11 @@ def test_filedropper_multiple_files(page): file2 = file1.parent / '__init__.py' page.set_input_files('input[type="file"]', [file1, file2]) + data1 = file1.read_text() + data2 = file2.read_text() + if sys.platform == 'win32': + data1 = data1.replace("\n", "\r\n") + data2 = data2.replace("\n", "\r\n") wait_until(lambda: len(widget.value) == 2) - assert widget.value == { - file1.name: file1.read_text().replace("\r\n", "\n"), - file2.name: file2.read_text().replace("\r\n", "\n"), - } + assert widget.value == {file1.name: data1, file2.name: data2} From 3ef749d66038167532c04fa7d8546bfdd705fc8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Mon, 27 May 2024 11:02:00 +0200 Subject: [PATCH 14/14] Add test for wrong file type --- panel/tests/ui/widgets/test_input.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/panel/tests/ui/widgets/test_input.py b/panel/tests/ui/widgets/test_input.py index 5cc79e4e07..272c9e64dc 100644 --- a/panel/tests/ui/widgets/test_input.py +++ b/panel/tests/ui/widgets/test_input.py @@ -739,6 +739,17 @@ def test_filedropper_text_file(page): data = data.replace("\n", "\r\n") assert widget.value == {file.name: data} +def test_filedropper_wrong_filetype_error(page): + widget = pn.widgets.FileDropper(accepted_filetypes=["png"]) + serve_component(page, widget) + + page.set_input_files('input[type="file"]', __file__) + + get_element = lambda: page.query_selector('span.filepond--file-status-main') + wait_until(lambda: get_element() is not None, page) + element = get_element() + wait_until(lambda: element.inner_text() == 'File is of invalid type', page) + def test_filedropper_multiple_file_error(page): widget = pn.widgets.FileDropper() serve_component(page, widget)