Skip to content

File Upload

A form with a file. The library renders the file input (with drag and drop) and sends the form as multipart/form-data; your route checks and stores the file.

models.py
from typing import Any

from pydantic_schemaforms import Field, FormModel


class UploadForm(FormModel):
    title: str = Field(title='Title', min_length=2)
    document: Any = Field(
        None,
        title='Document',
        description='PDF or Word, up to 5 MB.',
        ui_element='file',
        ui_options={'accept': '.pdf,.docx'},
    )


ALLOWED_TYPES = {'.pdf', '.docx'}
MAX_BYTES = 5 * 1024 * 1024
main.py
from pathlib import Path

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from models import ALLOWED_TYPES, MAX_BYTES, UploadForm
from starlette.datastructures import UploadFile

from pydantic_schemaforms import html, render_form_html_async

app = FastAPI()
FORM_OPTIONS = {'submit_url': '/upload', 'include_framework_assets': True, 'asset_mode': 'cdn'}


def check_upload(upload: object) -> tuple[str | None, bytes]:
    if not isinstance(upload, UploadFile) or not upload.filename:
        return 'Choose a file to upload', b''
    if Path(upload.filename).suffix.lower() not in ALLOWED_TYPES:
        return 'Upload a PDF or Word document', b''
    content = upload.file.read(MAX_BYTES + 1)
    if len(content) > MAX_BYTES:
        return 'The file is larger than 5 MB', b''
    return None, content


@app.get('/upload', response_class=HTMLResponse)
async def show_upload():
    return await render_form_html_async(UploadForm, **FORM_OPTIONS)


@app.post('/upload', response_class=HTMLResponse)
async def upload(request: Request):
    form = await request.form()
    result = UploadForm.validate(form, **FORM_OPTIONS)
    file_error, content = check_upload(form.get('document'))
    if not result.is_valid or file_error:
        errors = {**result.errors, **({'document': file_error} if file_error else {})}
        return await render_form_html_async(
            UploadForm, form_data={'title': form.get('title')}, errors=errors, **FORM_OPTIONS
        )
    size = len(content)
    return html(t'<p>Stored "{result.data["title"]}" ({size} bytes).</p>')
main.py
from pathlib import Path

from flask import Flask, request
from models import ALLOWED_TYPES, MAX_BYTES, UploadForm
from werkzeug.datastructures import CombinedMultiDict, FileStorage

from pydantic_schemaforms import html, render_form_html

app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = MAX_BYTES + 64 * 1024  # Flask refuses bigger requests
FORM_OPTIONS = {'submit_url': '/upload', 'include_framework_assets': True, 'asset_mode': 'cdn'}


def check_upload(upload: object) -> tuple[str | None, bytes]:
    if not isinstance(upload, FileStorage) or not upload.filename:
        return 'Choose a file to upload', b''
    if Path(upload.filename).suffix.lower() not in ALLOWED_TYPES:
        return 'Upload a PDF or Word document', b''
    content = upload.stream.read(MAX_BYTES + 1)
    if len(content) > MAX_BYTES:
        return 'The file is larger than 5 MB', b''
    return None, content


@app.get('/upload')
def show_upload():
    return render_form_html(UploadForm, **FORM_OPTIONS)


@app.post('/upload')
def upload():
    result = UploadForm.validate(CombinedMultiDict([request.form, request.files]), **FORM_OPTIONS)
    file_error, content = check_upload(request.files.get('document'))
    if not result.is_valid or file_error:
        errors = {**result.errors, **({'document': file_error} if file_error else {})}
        return render_form_html(
            UploadForm,
            form_data={'title': request.form.get('title')},
            errors=errors,
            **FORM_OPTIONS,
        )
    size = len(content)
    return html(t'<p>Stored "{result.data["title"]}" ({size} bytes).</p>')

How it works

  • ui_element="file" renders the file input. accept limits what the file picker offers; multiple=True would allow several files. A form with a file input gets enctype="multipart/form-data" by itself.
  • The field is typed Any, because the uploaded file arrives as your framework's own object: Starlette's UploadFile or Werkzeug's FileStorage. Flask keeps files apart from the other fields, so the route combines request.form and request.files before validating.
  • check_upload() checks the file itself: that one was chosen, its extension, and its size, reading at most one byte more than the limit. Its message is added to the form's errors so it shows under the file input.
  • The browser can't keep a chosen file across a re-render, so after an error the user picks it again. The title is kept.

Treat uploads as untrusted

accept and the file name are only hints: check the content before you use it, store files outside your web root under names you generate, and never show a file name in HTML without escaping it (html(t"...") does).