Skip to main content

File uploads in Pages

This page documents the exact contract for uploading files from the browser to a Python function registered with @register_function. It is written prescriptively so that humans and LLMs generating code get the contract right on the first try.

TL;DR

  • JS side: call the generated wrapper and pass the File directly. Do not hand-roll fetch + FormData.
  • Python side: annotate as dict (or leave it unannotated). The parameter arrives as {"filename": str, "content_type": str, "content": bytes}not a Path, not a file-like object.
from pathlib import Path

from abstra.pages import register_function
from abstra.common import get_persistent_dir


@register_function
def upload_file(file: dict):
# Always sanitize filename — it comes from the client and may contain traversal
safe_name = Path(file["filename"]).name
dest = get_persistent_dir() / "uploads" / safe_name
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(file["content"])
# Return a FileResponse-shaped dict, mirroring Forms' FileInput contract:
# {'name': str, 'path': pathlib.Path}. Path is serialized as a string on
# the wire since JSON can't represent pathlib.Path directly.
return {"name": safe_name, "path": str(dest)}


@register_function
def __render__():
return """
<input type="file" id="f">
<button onclick="go()">Upload</button>
<script>
async function go() {
const file = document.getElementById('f').files[0];
const result = await upload_file(file); // pass the File directly
console.log(result);
}
</script>
"""

That's the whole API. The rest of this page is details and anti-patterns.

Return shape

The example returns {"name": str, "path": str} — the same shape as Forms' FileResponse (described there as "a dictionary-like object: {'name': str, 'path': pathlib.Path}"). Using the same convention across Forms and Pages keeps downstream code uniform.

How it works

When you decorate a Python function with @register_function, Abstra auto-generates a matching async JavaScript function and injects it into the page's HTML. Calling that generated function from the browser does an HTTP POST to the page URL.

The generated wrapper inspects its arguments at call time:

  • If any argument is a File or Blob, it builds multipart/form-data, appends a hidden __function__ field that tells the server which function to dispatch to, appends each file as a file part, and JSON-encodes the remaining arguments as form fields.
  • Otherwise it sends a plain application/json body.

You do not need to think about this — just pass the File to the generated function.

The file parameter (Python side)

A file argument arrives as a dict with exactly these keys:

KeyTypeExample
filenamestr"report.pdf"
content_typestr"application/pdf"
contentbytesb"%PDF-1.4\n…" (raw bytes)

The file is in memory as bytes. There is no temp path on disk; if you need one, write the bytes yourself with Path.write_bytes(file["content"]).

Type annotation

Use dict, Dict[str, Any], or no annotation at all. Do not use Path, BinaryIO, UploadedFile, or bytes — the annotation is not read by the SDK, but using a wrong one will mislead readers (and LLMs) into calling file.name, shutil.copy2(file, …), or file.read(), all of which fail.

Multiple files (same input, multiple attribute)

If the <input type="file" multiple> selected several files and the JS called upload(files) with the FileList or an Array of Files, each is sent as a separate part under the same name and the Python parameter receives a list of dicts:

@register_function
def upload_many(files):
# files is a list[dict] when multiple files were sent
for f in files:
print(f["filename"], len(f["content"]))

To always get a list, iterate files client-side and call the function once per file — that avoids the "single dict vs. list of dicts" branching.

Calling from JavaScript

✅ Correct — use the generated wrapper

const file = document.getElementById('f').files[0];
const result = await upload_file(file);
// Mixed arguments work too — non-file args ride along in the same multipart body
const result = await upload_file(file, "my-category", {tag: "invoice"});
// Iterating a FileList
for (const f of input.files) {
await upload_file(f);
}

❌ Do NOT hand-roll fetch + FormData

The generated wrapper is load-bearing — it adds the hidden __function__ field that tells the server which registered function to dispatch to. Hand-rolled requests almost always forget this and fail with Function 'None' not found.

// WRONG — server has no idea which function to call
const fd = new FormData();
fd.append('file', file);
await fetch('', {
method: 'POST',
headers: {'X-Abstra-Function': 'upload_file'}, // ← not a real header
body: fd,
});

There is no X-Abstra-Function header, no convention where the URL path selects the function, and no query-string routing. The function name must be in the request body — and the generated wrapper is the only thing that puts it there correctly. Use it.

If you genuinely need a hand-rolled request (rare), the minimum required shape is:

const fd = new FormData();
fd.append('__function__', 'upload_file'); // required: identifies the function
fd.append('file', file); // your file parts
// non-file params must be JSON-encoded so they round-trip back to Python types
fd.append('tag', JSON.stringify({kind: 'invoice'}));

await fetch('', {method: 'POST', body: fd});
// do NOT set Content-Type — the browser sets it with the correct boundary

Server-side pitfalls to avoid

❌ Treating the file as a Path or file-like object

# WRONG — file is a dict, not a Path
@register_function
def upload_file(file: Path):
original_name = file.name # AttributeError
shutil.copy2(file, dest_path) # TypeError

✅ Read the bytes out of the dict

@register_function
def upload_file(file):
dest = UPLOAD_DIR / file["filename"]
dest.write_bytes(file["content"])

❌ Expecting streaming / chunked uploads

The whole file is loaded into memory as bytes before your function is called. There is no streaming API. For very large files, upload to object storage from the browser directly and pass the resulting URL/key to a registered function instead.

Returning a file to the browser

This contract is upload-only. Return values from registered functions are JSON-serialized, so you cannot return raw bytes. If you need to let the user download a file:

  • Return a URL (e.g. a pre-signed S3 URL), or
  • Base64-encode the bytes in your return value and decode client-side, or
  • Serve the file through a separate static asset via sdk.register_static(path).

Request size

Files travel through the same execution pipeline as regular calls. Very large files (tens of MB and up) may hit transport limits. For large uploads, upload to object storage directly and hand the server only a URL or key.

Troubleshooting

SymptomMost likely cause
Function 'None' not foundHand-rolled fetch without __function__ in the FormData. Use the generated wrapper.
AttributeError: 'dict' object has no attribute 'name'Treating file as a Path. Use file["filename"].
TypeError: copy2(src, …) on the file argSame — file is a dict, not a path. Use Path.write_bytes(file["content"]).
Upload appears to hangVery large file; see "Request size" above.

Server logs for each call include the transport (application/json vs multipart/form-data), the function name, and — on multipart — how many file parts were received. Check those logs first when an upload "didn't arrive".