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
Filedirectly. Do not hand-rollfetch+FormData. - Python side: annotate as
dict(or leave it unannotated). The parameter arrives as{"filename": str, "content_type": str, "content": bytes}— not aPath, 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.
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
FileorBlob, it buildsmultipart/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/jsonbody.
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:
| Key | Type | Example |
|---|---|---|
filename | str | "report.pdf" |
content_type | str | "application/pdf" |
content | bytes | b"%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
| Symptom | Most likely cause |
|---|---|
Function 'None' not found | Hand-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 arg | Same — file is a dict, not a path. Use Path.write_bytes(file["content"]). |
| Upload appears to hang | Very 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".