From 55f71a9380eeba6e65b140d8f7ba301525ca7699 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 30 May 2024 04:33:57 -0400 Subject: [PATCH] Add FileDropper widget (#6826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Simon Høxbro Hansen --- examples/reference/widgets/FileDropper.ipynb | 176 +++++++++++++++++ panel/config.py | 2 + panel/models/file_dropper.py | 67 +++++++ panel/models/file_dropper.ts | 195 +++++++++++++++++++ panel/models/index.ts | 1 + panel/styles/models/filedropper.less | 4 + panel/tests/ui/widgets/test_input.py | 55 +++++- panel/widgets/__init__.py | 5 +- panel/widgets/input.py | 130 ++++++++++++- 9 files changed, 625 insertions(+), 10 deletions(-) create mode 100644 examples/reference/widgets/FileDropper.ipynb create mode 100644 panel/models/file_dropper.py create mode 100644 panel/models/file_dropper.ts create mode 100644 panel/styles/models/filedropper.less diff --git a/examples/reference/widgets/FileDropper.ipynb b/examples/reference/widgets/FileDropper.ipynb new file mode 100644 index 0000000000..c076ece760 --- /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_dropper)" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/panel/config.py b/panel/config.py index e02de70bfc..5f7955d03f 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/file_dropper.py b/panel/models/file_dropper.py new file mode 100644 index 0000000000..7757fa3abc --- /dev/null +++ b/panel/models/file_dropper.py @@ -0,0 +1,67 @@ +from bokeh.core.properties import ( + Bool, Dict, Enum, Int, List, Nullable, String, +) +from bokeh.events import ModelEvent +from bokeh.models.widgets import InputWidget + +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 DeleteEvent(ModelEvent): + + 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(10_000_000) + + max_files = Nullable(Int) + + max_file_size = Nullable(String) + + max_total_file_size = Nullable(String) + + mime_type = Dict(String, String) + + multiple = Bool(True) + + layout = Nullable(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" + ] + + @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", + f"{config.npm_cdn}/filepond-plugin-pdf-preview/dist/filepond-plugin-pdf-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..1ba10ddd2c --- /dev/null +++ b/panel/models/file_dropper.ts @@ -0,0 +1,195 @@ +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 {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 { + 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 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).FilePondPluginPdfPreview); + (window as any).FilePond.registerPlugin((window as any).FilePondPluginFileValidateType); + (window as any).FilePond.registerPlugin((window as any).FilePondPluginFileValidateSize) + } + + override connect_signals(): void { + super.connect_signals() + 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, + }) + }) + } + + override remove(): void { + if (this._file_pond) { + this._file_pond.destroy() + } + super.remove() + } + + override stylesheets(): StyleSheetLike[] { + 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._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, + maxFiles: this.model.max_files, + 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, + ) => { + void this._process_upload(file, load, error, progress) + }, + fetch: null, + revert: (id: string, load: () => void): void => { + this.model.trigger_event(new DeleteEvent({name: id})) + load() + }, + }, + stylePanelLayout: this.model.layout, + }) + } + + 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 + 1)/ buffer_size) // +1 is for empty files + 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({ + chunk: i+1, + 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) + } + load(file.name) + resolve(file.name) + }).catch(() => error("Upload failed.")) + + return {abort: () => { + abort_flag = true + }} + } +} + +export const DropperLayout = Enum("integrated", "compact", "circle") + +export namespace FileDropper { + export type Attrs = p.AttrsOf + export type Props = InputWidget.Props & { + accepted_filetypes: p.Property + chunk_size: 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 + } +} + +export interface FileDropper extends FileDropper.Attrs {} + +export class FileDropper extends InputWidget { + declare properties: FileDropper.Props + + static override __module__ = "panel.models.file_dropper" + + constructor(attrs?: Partial) { + super(attrs) + } + + static { + this.prototype.default_view = FileDropperView + 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 ], + max_total_file_size: [ Nullable(Str), null ], + mime_type: [ Any, {} ], + multiple: [ Bool, true ], + layout: [ Nullable(DropperLayout), null ], + })) + } +} diff --git a/panel/models/index.ts b/panel/models/index.ts index 821dee4830..4e448b9005 100644 --- a/panel/models/index.ts +++ b/panel/models/index.ts @@ -17,6 +17,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" 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/tests/ui/widgets/test_input.py b/panel/tests/ui/widgets/test_input.py index 4177133957..272c9e64dc 100644 --- a/panel/tests/ui/widgets/test_input.py +++ b/panel/tests/ui/widgets/test_input.py @@ -1,4 +1,7 @@ import datetime +import sys + +from pathlib import Path import numpy as np import pytest @@ -7,7 +10,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 +724,53 @@ 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) + data = file.read_text() + if sys.platform == 'win32': + 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) + + 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]) + 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: data1, file2.name: data2} diff --git a/panel/widgets/__init__.py b/panel/widgets/__init__.py index c69f38061c..ee8b249c4f 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, + FileDropper, 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..ad9e9d55b8 100644 --- a/panel/widgets/input.py +++ b/panel/widgets/input.py @@ -24,6 +24,7 @@ PasswordInput as _BkPasswordInput, Spinner as _BkSpinner, Switch as _BkSwitch, ) +from pyviz_comms import JupyterComm from ..config import config from ..layout import Column, Panel @@ -31,7 +32,7 @@ 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,6 +286,111 @@ def save(self, filename): fn.write(val) +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, 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.""") + + layout = param.Selector( + 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 + 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.""") + + 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=""" + Maximum size of all uploaded files, as a string with units given + in KB or MB, e.g. 5MB or 750KB.""") + + mime_type = param.Dict(default={}, doc=""" + A dictionary containing the mimetypes for each of the uploaded + files indexed by their filename.""") + + multiple = param.Boolean(default=False, doc=""" + 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): + 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: + 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('delete_event', 'upload_event', model=model, doc=doc, comm=comm) + return model + + 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(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): """ The `StaticText` widget displays a text value, but does not allow editing