Changelog
August 9, 2026 · View on GitHub
[2.5.0] - 2026-08-08
FuncToWeb now depends directly on Starlette instead of FastAPI. app_of()
returns a mountable Starlette/ASGI application, and run() serves that same
application with Uvicorn. FastAPI remains a naturally compatible host through
app.mount(...), but is no longer a runtime dependency. There is no 2.4.0: the
numbering goes from 2.3.0 straight to 2.5.0.
Changed
- Breaking:
router_of()is replaced byapp_of()andinclude_router(..., prefix=...)becomesmount(prefix, app_of(...)). - Breaking:
fastapi_kwargsis removed fromrun();uvicorn_kwargsremains unchanged. - Breaking: the per-router dependencies of
include_router(..., dependencies=[Depends(...)])have nowhere to go, because a mount is an application inside another and not a router:mount()takes nodependenciesand raisesTypeErrorfor it. A host that authenticated the space that way applies the same check as middleware around the mount, wrappingapp_of(...)before mounting it. Dropping the argument to get past theTypeErrorleaves the space mounted and unauthenticated. - A
POSTcarryingContent-Type: text/plainwith a valid JSON body now answers200. FastAPI read the declared content type first and took the body for a string, which was a422; the body is parsed as JSON whatever the header says. - A route that answers
GETnow answersHEADas well, which is the behaviour ofstarlette.routing.Route; with FastAPI's own routerHEADwas a405. - Invalid or non-object JSON keeps status
422but now uses a small FuncToWeb transport error instead of FastAPI/Pydantic's validation payload. - The application index at
/is part ofapp_of()as well as standalonerun().
Dependencies
- Replaced
fastapi==0.121.1withstarlette==0.49.3at runtime. - FastAPI is retained only in the test extra to verify host compatibility.
[2.3.0] - 2026-08-08
Three things about the page a host opens. A function that prints in a loop no
longer pushes the rest of the page down for as long as it runs; a modal is as
tall as the window allows instead of a fixed 760px; and an opening can be told
to run itself, for the modal you open to see an answer rather than to fill in
a form. The install also loses a name: pytypehint arrives with
pytypehintweb and no longer needs pinning twice.
Added
-
autorun: an opening that runs itself — a page opened withautorunpresses its own submit button as soon as it is mounted. It is the third parameter of an opening, besideprefillandhidden, and it travels the same two roads:?autorun=1onGET /{slug}/, andpage_of(…, autorun=True)from Python. In the SDK it is an option ofpageUrl(),embed()andopenModal().It exists for the modal you open to see the answer rather than to fill in a form: a report, a chart, a generated file, a link. Those functions usually take no parameters, or take them all prefilled by the host, so the button is a step with no decision in it.
call()skips the button too, but it hands back JSON and leaves the host to draw the table, the image or the download — which is the work this library has already done.What it does is press the button, and nothing else: the click that follows is the ordinary one, with the same validation, the same uploads, the same stream, the same result card and the same announcements to the host. It presses once, at mount, so a result that opens another form does not inherit it.
-
An incomplete form is left alone — if the form is not ready, autorun does nothing at all: no errors shown, no fields marked. Someone who has just opened a modal has not typed anything yet and has nothing to fix; the missing field is on screen, and their click is what the page was waiting for anyway. From there it is an ordinary page, and clicking submit runs it.
Changed
- A modal is as tall as the window allows, instead of 760px — the panel
was a fixed , so on any ordinary screen a form was shown through a
letterbox with a scrollbar over it while several hundred pixels sat unused
around it. Height now follows the window —
90vh, capped at what the overlay leaves— and only the width stays a number, because a form grows downwards and not sideways. Measured on a 1280×1080 window: the panel goes from 760px to 836px, and the forms that used to be cut off by a hundred pixels are shown whole. - The size is configurable, in whatever unit suits —
--ftw-modal-widthand--ftw-modal-heightfor every modal, oropenModal(url, {width, height})for one, which sets those same variables on that panel. Both take any CSS length —px,%,vh,rem,calc()— and the option also takes a plain number, read as pixels; anything else raises aFuncToWebError. Both stay insidemin(…, 100%), so no setting can put a corner of the modal outside the window. - What a function prints is shown in a window of its own, and stays there —
a loop that prints pushed the result, the form and the button further down
with every line, so a long run left the page unusable and the answer
somewhere below the fold. The printed output now gets about ten lines of
height (
--ftw-stdout-max-height,14rem) and scrolls inside them: the page keeps its shape however long the run is. - The box follows the output, unless you are reading it — being at the bottom means the last line printed is the one on screen, which is the point of watching a function narrate itself. Scrolling up stops it following, because a jump on every event makes the box unreadable for exactly as long as the function keeps printing; returning to the bottom starts it again. It also follows once more when the result lands, since drawing the answer replaces the children of the result area and a node put back into the document comes back scrolled to the top — without that, a run that printed ended showing its first line.
- The page keeps a bounded amount of printed text — the last 40,000
characters, cut on a line break, with
… earlier output trimmedas the first line when anything was dropped. A loop printing without end grew one string in the DOM until the tab stopped answering: a crashed page, not a long run. This is what the page holds;/invoke-streamstill sends everyprintevent in full, so a client of your own still receives everything.
Removed
pytypehintis no longer listed as a direct requirement —pytypehintwebrequires it, so it arrives with it and pinning it in two places was one more pair to keep in step. What gets installed does not change; the declaration does. The three names in the install are nowpytypehintweb,fastapianduvicorn.
Documentation
prefill.md— the page is now about the three parameters of an opening rather than two, with a Running the opening on its own section: what autorun is for, that it presses the button and nothing else, what happens to an incomplete form, and the two things it is not — a loop and a permission.page_of()gains the argument in its published signature.sdk.md—autorunin Embedding a function page, with the modal opened for its answer beside the ones opened to be filled in, and why that is not the same as callingcall(). A new The size of a modal section: the default and why height is the axis that follows the screen, the two ways to change it, the cap that keeps any of them on screen, and the one case no setting fixes.streaming.md— a new What printing a lot looks like section under In the web interface: the window and its CSS variable, when the box follows and when it leaves you alone, the character limit and its notice, and the distinction between what the page keeps and what the endpoint sends.examples/fastapi/modal_autorun.py— the 81st example: a host page whose three buttons open modals that run themselves, one taking no parameters, one prefilled and hidden by the host, and one with a required field nobody filled, which is the case where autorun does nothing and waits.
[2.2.0] - 2026-08-08
One dependency less. platformdirs was in the install for a single call in
config.py —where the default uploads directory lives— and that call asked for
nothing the library is interesting for: no version, no roaming, no author, no
directory creation. Of its ~1,950 lines the package was using three branches of
one if, and now those three branches are fifteen lines of config.py that
say the same thing.
The point is not the lines saved, it is the install. None of the other four
requirements pulls platformdirs in, so it really does leave the tree; and
being a small general-purpose utility, it is the one requirement of the five
that a user's environment is likely to want at another version. Pinned as
platformdirs==4.5.0, that meeting had no solution. The stack a user installs
is now four packages, all of which this project either writes or serves on.
Removed
platformdirsis no longer a dependency — the default data directory is read from the platform itself:%LOCALAPPDATA%on Windows, falling back to the home directory when the process was handed an environment without it;~/Library/Application Supporton macOS; and$XDG_DATA_HOME, or~/.local/share, everywhere else. On Windows the variable and the shell API the library called are the same source of truth —the system sets the first from the second at login— so this is the same answer by a shorter road, and one that also honours a variable someone set on purpose.
Fixed
- A blank directory variable no longer yields a relative path — writing the
branches out surfaced a case the library did not cover either: with
XDG_DATA_HOMEor%LOCALAPPDATA%exported as an empty or whitespace-only value —what a shell leaves behind when it exports nothing— the variable was joined as it was, and the default became a path relative to whatever directory the process was started in. A variable that holds only blanks now counts as unset, which is what the XDG spec says to do with it.
Compatibility
- The directory does not move — the new code was compared against
platformdirson the three platform classes for the call this package made, in the default case and with the variables set, empty, blank and absent, and the paths are identical. An existing installation finds its uploads where it left them, and nothing aboutuploads_dir,FUNCTOWEB_UPLOADS_DIRor the precedence between them changes. - What is dropped is Android and an 8.3 path —
platformdirsrecognises a native Android runtime and answers with the app's private storage. That detection is gone, and a genuine Android app would now get the XDG answer; Termux, which is where this could realistically run, already took the XDG branch inside the library and is unaffected. On Windows the library also downgraded a profile with non-latin-1 characters to its short8.3form, a Python 2 inheritance fromappdirs: the long path is returned now, which is the better one. - The version is 2.2.0 and not 2.1.2 — nothing in the public API changes, but a release that alters what gets installed and how a default is computed is not a patch.
Internal
tests/unit/test_user_data_dir.py— the three platforms are faked, so every branch is read on whichever machine runs the suite: the variable, its absence, its blank forms, the name landing last, that asking creates nothing and that the answer is absolute. One test compares the result againstplatformdirswhen it happens to be installed and skips itself when it is not — the oracle for the change, without becoming a dependency of the tests.- The clean-import probe reads the defaults from the package — it used to
recompute them with
platformdirsbefore importingfunc_to_web, which on Windows meant the library ignored the sanitised environment the probe had built and pointed at the realAppData: the assertion that importing creates no directory was reading a path outside the sandbox. It now takes both directories fromfunc_to_web.configafter the import, and the probe is hermetic on Windows too. Where the defaults point is what the new file above tests.
[2.1.1] - 2026-08-03
Copying a result works on a page that is not served from localhost. Both copy
buttons went straight to navigator.clipboard, which exists only in a secure
context, and a panel read over plain http from a LAN address is not one. There
the property is undefined, the click threw, and the button swallowed the error
and did nothing — no tick, no message, an empty clipboard and no way to tell
why. This is where these apps are usually read, so it is the case that matters.
Text now falls back to a hidden textarea and document.execCommand("copy"),
which has no such restriction. The element is removed in a finally, so a
browser that refuses the copy does not leave it in the document, and a refusal
raises rather than reporting success: the tick means the clipboard changed.
A picture gets no fallback, because there is none to give. execCommand copies
a selection and no selection carries an image. Inventing one would mean a button
that ticks over an empty clipboard, which is worse than one that does not offer.
So the image button is disabled outside a secure context and says why, and
points at the download beside it, which works everywhere.
The check is isSecureContext and the presence of the API, not the protocol:
localhost is granted a secure context over plain http, and reading the scheme
would have sent it down the fallback path for no reason.
[2.1.0] - 2026-07-30
The feature of this release is the channel back from an embedded page. Until now a host application could open a function in a modal and learn nothing from it: the result was drawn inside the iframe and stayed there, so a page could not refresh its list after a create task or close a dialog once a submit had worked. Now the page announces what happens inside it, and the host waits on a promise. Nothing existing changes behaviour — see Compatibility below for the one exception, which is the slug.
Added
- An embedded page announces its runs to whoever embeds it — a new static
asset,
emit.js, posts one message towindow.parentper event:readywhen the form is mounted,resultwith the outputs a run just drew,errorwith theerrorof the envelope, andnavigatewith thehrefanOpenFormis about to move the iframe to. Every message carriesv—the protocol version,1— and theslugit comes from. A page nobody embeds (window.parent === window) posts nothing at all and does not fail.targetOriginis"*", because the page does not know who embedded it:security.mdstates the assumption. openModal()returns aclosedpromise — it resolves once, when the modal closes by any route (the close button, a click outside,Escape,closeOnResult, a programmaticclose()), with{completed, results}:completedis true if at least one run finished inside, andresultsis the outputs of the last one, ornull. A modal dismissed without running anything resolves{completed: false, results: null}— the same shape, no special case. The handle is returned synchronously and unconditionally, so a URL that answers404still gives a closable modal whose promise resolves.closeOnResult,onResultandonErroronopenModal()—closeOnResultdefaults tofalse, because a result is drawn inside the page and closing the overlay would throw away an image, a table or a download the user opened it to see; turn it on for a form whose result is a confirmation.listen(iframe, handlers)— the same announcements for an iframe you mounted yourself, withembed()or by hand. It filters byevent.source, ignores in silence anything whosevit does not know, has nokind, or carries akindit has not heard of, and returns{cache, stop};cacheholds the last of each. Every handler —onReady,onResult,onError,onNavigate— is optional.openModal()is built on it.erroris a run that failed, never a field the browser rejected — one kind means one thing, so a host can act on it. Client-side validation stays silent, andnavigateis emitted instead ofresult, because moving to another form is not a result and must not makecompletedtrue.
Changed
- The default slug is
fn.__name__as it is —create_taskis served at/create_task/. The derivation lowercased the name and collapsed every_into a-, while the published contract (docs/migration-1.6-to-2.0.md,docs/design/history-1.6-to-2.0.md) stated the opposite: code and contract had diverged, and it is resolved in favour of the contract, because a rule that transforms nothing is the one a reader can predict. A hyphenated URL is still available where it is wanted:WebFunction(edit_product, slug="edit-product"). The displayed name is untouched — the<title>, the<h1>and therun()index still prettifycreate_taskintoCreate task. The two pages that stated the rule needed no correction to it: the code moved to meet them, and the contract won. - A slug accepts letters, digits and underscores — the old validation took
lowercase letters, digits and single hyphens only, and would have refused
almost every name the rule above derives. It now takes any letter, digit or
_, plus single internal hyphens, which also makes a hand-writtenslug="hello_world"orslug="Hello"valid where it used to raise. What the frontier rejects is unchanged: spaces,/,\, dots,%, non-ASCII characters, double hyphens and a leading or trailing hyphen.doc,static,uploadandreturnsstay reserved.
Compatibility
- The channel is entirely additive — a host that uses none of it behaves
exactly as before: the modal handle keeps
element,iframeandclosewith the same meanings and merely gainsclosed,closeOnResultdefaults to the old behaviour, and a page embedded by a host that does not listen runs as it always did. No signature loses an argument and no return value loses a member. - The slug is the one behaviour change, and it moves URLs — a function whose
name carries
_was served at the hyphenated URL in 2.0.0 and is served at the underscored one now:/create-task/becomes/create_task/. A deployment that published those URLs, or a client that hard-coded them, pins the old form explicitly withWebFunction(create_task, slug="create-task"), which is unchanged and still valid. Nothing else about a function moves: the displayed name, the plan, the envelope and the reserved slugs are the same.
Documentation
sdk.md— a new Reacting to the modal section coveringclosed, the three options,listen()for an iframe of your own, and the versioned protocol with the exact payload of each kind. The page previously stated that "there is still no communication back from the iframe to the host application"; what is still missing is now only the iframe's height and the host-to-page direction.security.md— thetargetOrigin "*"of the announcements, stated beside the prefill assumption it resembles: whoever can embed a page can read what runs inside it.design/sdk.md— a new note on whycloseOnResultis off by default, whyerrorskips client-side validation, and why the payload ofresultreuses the envelope of/invokeinstead of a shape of its own.static-assets.md—emit.jsin the table of FuncToWeb's own assets.web-function.md— theSlugsection documented the old derivation in detail, with its own table of examples; it now describes the rule as it is, says that hyphens remain available throughslug=, and states that slugs are case-sensitive because URLs are.- URLs in the pages and examples —
http.md,prefill.md,outputs.mdandmigration-1.6-to-2.0.mdshowed the hyphenated form of a function whose name carries_, as didexamples/fastapi/iframe_host.pyandexamples/themes/router_theme.py. README.md— a new Change once, propagate everywhere section walking through the todo CRUD: the model written once, the three functions that only reference it, and what adding a singledue_datefield reaches without any other edit (with the screenshot of the resulting form). The opening also says what the library is for in one line: build your app however you like, and when it needs a form, open a Python function instead of writing one.examples/project/todo.py— a new example, the one the README walks through: three functions over one dataclass, mounted under/tools, with a host page of its own that opens each of them in a modal and refreshes its list fromclosed. It is the runnable demonstration of the channel above.examples/README.md— the collection now has two kinds of file: the 80 single-capability examples as before, and mini-apps inproject/, each combining several capabilities into one small complete application. Both are runnable programs, so the count in the main README is unchanged in meaning.- Repetition removed from five examples — a repeated
Annotatedalias extracted (examples/types/dataclass_input.py,examples/outputs_optional/numpy/matrix.py), aChoicesderived from the data it lists instead of restating it (examples/themes/open_form_theme.py), anOpenFormreturn that no longer declares a dataclass to carry one value (examples/files/file_prefill.py), and one HTTP client reusing another'sinvoke()andPREFIXrather than keeping its own copy (examples/http/upload_client.py, noted inexamples/http/README.md). No example changes what it teaches.
[2.0.0] - 2026-07-29
2.0 is not one more release: it is a different library built on the same idea.
Your function, its type hints, its dataclasses and its docstring still work.
What changes is how constraints are declared, how files travel, how results are
returned and which URLs the server serves. The two layers underneath were
rewritten — pytypeinput and pytypeinputweb give way to
pytypehint and pytypehintweb —
and pydantic goes with them: it is no longer involved at all. The minimum
Python version rises from 3.10 to 3.11.
A full read of docs/migration-1.6-to-2.0.md
is recommended before upgrading. It covers every case below with the exact
error each one produces, and ends with the ordered update steps.
From this version on, expect far fewer changes. The redesign is done: the API surface, the transport and the storage contract are what they are meant to be. What comes next are releases dedicated to security, to polishing what is already there, and to adding things that fit inside the current design — not to rewriting it again.
Added
- Nested structures with no depth limit — nested dataclasses, lists of
lists, and unions of several real types with their metadata declared per
branch (
Annotated[int, Min(0)] | str); over HTTP an ambiguous branch is discriminated with$type. The function receives fully built Python values. - Defaults are no longer shared between calls — they are validated when the
WebFunctionis built and rematerialized on every execution, so a mutable default is never carried from one call to the next. OpenForm— a function can return the data that another function's form opens with, marking its return annotation. Chaining two tools no longer needs a frontend.- Prefill from Python, and
page_of()— a page can open with real Python values already in place and with selected fields hidden.page_of()renders the complete HTML of one opening without mounting any route. - Input files are uploaded once and reused — raw bytes go to
POST /uploadwith anX-File-Referenceheader, and every later call sends that reference as an ordinary string. Re-running with a different parameter never moves the file again, and a reference can travel in a prefill so the form opens with the file already set. - A storage life cycle with two states — an upload is published as pending
and the first execution or prefill that resolves it promotes it: what is
promoted is permanent, what nobody ever claims expires after
pending_ttl. Returned files expire afterreturns_ttl. Both default to one hour, and both accept anint, atimedeltaorNone. uploads_dirandreturns_dirare resolved when the router is built — made absolute, created and checked there, so a directory this process cannot write to fails at build time instead of on the first request. Both can also be set from outside the code withFUNCTOWEB_UPLOADS_DIRandFUNCTOWEB_RETURNS_DIR; the argument wins over the variable.- Per-field file limits —
IsPathFile(min_size=…, max_size=…)bounds the file of that field, checked in the browser before uploading and again in the core before the function runs. POST /{slug}/invoke-stream— streaming has a route of its own:start, zero or moreprintevents and oneresultwith the same envelope as/invoke./invokenever streams.theme="system" | "light" | "dark"— chosen by whoever mounts the space and stamped on the initial HTML, so the page does not flicker.sdk.jsas a static asset of the space —call,callStreamandopenModalfor your own frontend, with no build step and no URL to assemble.GET /static/{path}withETag— subdirectories included, revalidated withIf-None-Matchand answered with304.MultipleOfis actually validated — 1.6 acceptedField(multiple_of=n)and silently discarded it.Extra(key, value)— a namespaced storage slot on a field, kept in the schema and interpreted by nobody, for wrappers that need to annotate a field.
Changed
create_app()→router_of(), which returns anAPIRouter— the prefix belongs toinclude_router(), and every URL the space emits is relative, so a router works under any prefix.root_pathis gone with the problem it solved.FunctionMetadata→WebFunction, with the callable positional and namedfn.__name__is used as is for both the name and the slug, soname=no longer changes the URL andslug=is there for that.doc,static,uploadandreturnsare reserved.- Every function lives at
/{slug}/, even when it is the only one — there is no special route for a single function;/{slug}answers307. The index at/is added byrun()and not byrouter_of(). POST /submitmultipart →POST /{slug}/invokewith JSON — one key per parameter: ISO dates, an enum by its member name, a nested dataclass, a file as its reference.GET /download/{file_id}becomesGET /returns/{reference}.- The response envelope no longer carries
success— a result is200 {"result": {…}}, a contract violation is422 {"error": "…"}and an exception raised by the function is500 {"error": "ZeroDivisionError: …"}instead of arriving inside the stream with200. The per-field error map is gone:erroris a string. - Constraints are atoms, not pydantic —
Field(ge=)/Field(le=)becomeMin/Max(withexclusive=Truefor the strict bounds),Field(pattern=)becomesPattern,Field(min_length=)/max_lengthbecomeMin/Maxon the string or on the list. AFieldleft in a signature fails when theWebFunctionis built. func_to_web.typesno longer exists — everything is imported fromfunc_to_web.Patternis afullmatchover a portable subset of RegExp —\d,\w,\s,\band the unescaped dot are rejected when building theWebFunction, and an unanchored pattern no longer accepts partial matches.Params→ a plain@dataclass—frozen=Trueis no longer required, it can nest others and be optional, and its fields are no longer flattened into the form, so names no longer collide.__post_init__is still where cross-field validation goes, and itsValueErrorstill surfaces as a422.- Downloads are declared, not detected — the return annotation carries
Downloadand the value merely satisfies it (aPath, astrwith a path orbytes). Three cases thatFileResponseused to decide at runtime are now rejected when theWebFunctionis built: a fixed name for more than one file,byteswith no filename, and a return mixingDownloadwithOpenForm. - Only three values are tables — a pandas
DataFrame, a polarsDataFrameand a 2D numpy array. Alist[dict]or alist[tuple]is no longer guessed into a table: it is flattened recursively into one text output per value. - A
Noneinside a collection emits its own"Done"— 1.6 skipped it, so["one", None, "two"]goes from two outputs to three. - Uploaded files are no longer deleted when the function finishes — 1.6 used
uploads_dir/<uuid>/<name>and removed that folder; in 2.0 what an execution received stays, which is what makes a reference reusable. - Storage is one policy per process, not one per router — the first router
with file fields settles
uploads_dirandpending_ttl(and the first with aDownload,returns_dirandreturns_ttl) for every space mounted beside it. A later router asking for a different one gets aUserWarningsaying it was ignored, instead of being left guessing. hostdefaults to127.0.0.1instead of0.0.0.0: the loopback interface only, unless you say otherwise.run()renames most of its arguments and they are keyword-only —func→fns,app_title→title,stream_prints→capture_prints,max_file_size→max_upload_bytes,fastapi_config→fastapi_kwargs, and the loose Uvicorn keys are grouped intouvicorn_kwargs={…}. Four reserved keys raiseTypeError:titleinfastapi_kwargs, andhost,portandappinuvicorn_kwargs.capture_printsis decided per space and per function —WebFunction(f, capture_prints=…)overrides the space, and output is captured by default.- An
Enumtravels by member name only — 1.6 accepted the name or the value over HTTP. - An empty
strand an emptylistare now accepted — 1.6 rejected both with422. Require them explicitly withMin(1). /docs,/redocand/openapi.jsonare enabled again in the application built byrun(); turn them off withfastapi_kwargs={"docs_url": None, "redoc_url": None, "openapi_url": None}.workersandreloadare no longer validated byrun()— the key reachesuvicorn.run(), which explains that an import string is required.
Removed
- pydantic — no longer a dependency, and no longer involved in any part of the pipeline.
Dropdown(fn)— there are no dynamic options; a field with runtime options has to be redesigned by hand with fixed ones.- The file aliases (
ImageFile,TextFile,AudioFile,DataFile,VideoFile,DocumentFile,File) — a file field is astrannotated withIsPathFile(extensions=…); the migration guide lists the exact extensions each alias carried. OptionalEnabledandOptionalDisabled— replaced byAnnotated[X | None, OptionalToggle(True | False)].FileResponse— superseded byDownloadin the return annotation.css_vars,faviconandlist_css_variables— appearance is controlled bytheme=; the--pth-*tokens exist but no public API reaches them.root_path— every URL is relative, so there is nothing to prefix.?__embed=1— every page is complete and embeddable as it is, with no headers that block embedding; the cosmetic stripping has no equivalent.- A
floatLiteralandSlideron afloat— the first is replaced byAnnotated[float, Choices(values=(…))], and the second either becomes anintfield or drops the slider.
Documentation
- The documentation was rewritten around the new library — one page per
area (
run,router,types,files,outputs,prefill,open-form,streaming,http,sdk), pluslimitationsandsecuritystating what the library does not do. - A complete migration guide —
migration-1.6-to-2.0.md, with equivalence tables per area, the confirmed pitfalls and the ordered update steps. - The examples collection was rebuilt — 80 runnable programs across 11 folders, each file teaching a single capability and running as it is.
[1.6.0] - 2026-06-06
Security
(by https://github.com/Dr1985)
- Fixed a path traversal vulnerability in file uploads. The original
filename from the multipart request was joined into the save path
without sanitization, allowing
../sequences to escapeuploads_dirand write files to arbitrary locations. Filenames are now reduced to their final path component. Thanks to the reporter for the catch.
Fixed
- The package no longer ships unrelated top-level folders —
setuptoolswas discovering every directory that looked like a package, sopip install func-to-webdropped_private/,docs/andexamples/into the user'ssite-packagesas global top-level packages (visible in the oldtop_level.txt). Packaging is now scoped withinclude = ["func_to_web*"]in[tool.setuptools.packages.find], so onlyfunc_to_webis installed. - Returned files are now stream-copied instead of being read fully into RAM —
save_returned_fileusedPath(...).read_bytes()+write_bytes()for theFileResponse(path=...)case, loading the entire file into memory just to rewrite it (a 2 GB return meant 2 GB of RAM), which defeated the whole point of passingpath=. It now copies in 8 MB chunks viashutil.copyfileobj. The in-memorydata=branch is unchanged. - Result serialization no longer blocks the event loop — the function's
return value was serialized (writing returned files, base64-encoding
PIL/matplotlib PNGs, building tables) directly inside the running coroutine.
For
asyncfunctions this always ran on the event loop; forsyncfunctions theto_threadwrapper only covered the call itself, not the serialization. A large output froze the server for every connected user.process_resultnow runs viaasyncio.to_thread, moving all heavy CPU/IO off the loop regardless of whether the user's function is sync or async. - The per-function param list is no longer mutated concurrently —
create_handlersanalyzed the function once into a singleparamslist that was captured by thepage_handler/submit_handlerclosures and shared across every request.page_handlerrewrote it in place on each render (refresh_params), so two concurrent requests — e.g. a slowDropdown(func)refresh racing another render, or a render racing a submit's validation — could serialize or validate against a half-refreshed list. The shared list is now an immutable template (base_params); each page render builds its own refreshed copy ([p.refresh_choices() for p in base_params]), and submit validation reads the template directly (dynamic dropdown options aren't validated server-side, so the submit never needed fresh choices). No shared mutable state, no locks — the same principle as the 1.5.0 globals cleanup, applied to the last place mutable shared state remained. - Invalid
Paramssetups are now rejected at startup with a clear error instead of misbehaving silently — three cases that previously slipped through: a field name colliding across the flat form (aParamsfield matching a function parameter or a field from anotherParamsclass) madeparams_by_namekeep only the last one, so the form rendered duplicate inputs and validation/reconstruction stole values between them; a nestedParamsfield and an optionalParamsparameter (data: UserData | None, bothtyping.Unionand PEP 604) fell through to the generic analyzer and crashed with a cryptic internal error. All three now raise an explicitValueErrorwhen routes are registered, naming the offending field/parameter and how to fix it (rename the field; flatten the nested class; make individual fields optional inside the class instead). Valid code is unaffected. - Server-side validation errors (HTTP 422/400) are now shown in the UI instead
of being silently dropped — the frontend fed every submit response through
the SSE parser; a 422 is plain JSON, matched no SSE blocks, and was discarded
without a trace, so submits failing server-only validation (notably a
Params__post_init__raisingValueError) appeared to do nothing. Non-200 responses are now detected in both submit paths (fetch and XHR upload) and rendered in the error block, listing each offending field and its message. Client-side validation masked this for ordinary field constraints, which is why it went unnoticed until server-only validation existed.
Changed
Paramssubclasses are now frozen dataclasses —Paramswas an empty marker class and instances were rebuilt internally viaobject.__new__+ attribute assignment, which silently skipped any user-defined__init__and produced half-constructed objects. SubclassingParamsnow applies@dataclass(frozen=True)automatically: instances are constructible anywhere (UserData(name=..., email=...)), comparable, hashable, and immutable, withdataclasses.replace()for variants. Cross-field validation goes in__post_init__; aValueErrorraised there surfaces as a 422 form error. Breaking: defining a custom__init__is no longer supported (the dataclass generates it), and mutating a Params instance now raisesFrozenInstanceError. Internally, the manual_reconstructstep is gone — construction is justYourClass(**fields).
Removed
aiofilesdependency dropped — it was used in a single place, the chunked write loop insave_uploaded_file. The same non-blocking behavior is now achieved with a plainopen()andawait asyncio.to_thread(f.write, chunk)(the upload read staysawait uploaded_file.read(...)via Starlette'sUploadFile). At 8 MB chunks the thread-hop overhead is negligible. One fewer dependency, no behavior change.
Documentation
- Upload cleanup docs corrected —
files.mdclaimed FuncToWeb "skips cleanup on files that no longer exist in the original path", describing a mechanic that doesn't exist. The temporary upload folder is always removed after the function finishes;shutil.move()works because it moves the file out of that folder before deletion, not because of any skip. Paramscross-field validation documented —/docandapi-docs.mdnow note that a 422errorskey may be a single field or aParamsgroup whose__post_init__rejected an otherwise field-valid combination;params.mdadds that this validation runs server-side (the error appears on submit, not while typing) and aLimitationssection spelling out the nested / optional / duplicate-name cases that are rejected at startup.
[1.5.0] - 2026-06-04
This release is a big simplification pass. The goal: remove features that can be done more elegantly other ways, and make FuncToWeb composable.
1.5.0 takes FuncToWeb from "tool for spinning up mini programs" to "a library you can use both ways": run() serves your functions standalone, exactly as it always has, and the new create_app() returns a plain FastAPI app you can mount inside your own. Nothing from the original mode is lost — the auto-generated form UI, single/multi function apps, and everything you already use keep working exactly as before.
Added
-
create_app(): builds the FuncToWeb FastAPI application without starting a server. This makes the library embeddable — mount it inside a larger app — and unlocksuvicorn --workers N/--reloadby serving via import string (both are rejected byrun(), which serves an app instance):from fastapi import FastAPI from func_to_web import create_app host = FastAPI() host.mount("/tools", create_app([add, multiply]))All internal URLs are derived per request from the ASGI
root_path, so a mounted app works under any prefix with no configuration.run()is now a thin wrapper: guards + startup sweeps +create_app()+ Uvicorn.Note: the startup sweeps (leftover upload folders, expired returned files) run only in
run().create_app()deliberately skips them — under multiple workers, each worker builds its own app, and sweeping the shared directories on every build could delete a sibling worker's in-flight uploads during rolling restarts. Expired returned files are still cleaned opportunistically at runtime.
Changed
- Internal CSS/JS bundles are now built in memory and served from routes, not written to a temp dir —
create_pytypeinput_assets()(which concatenated pytypeinputweb +internal_staticassets and wrotestyles.css/scripts.jsto<temp>/func_to_web/static, mounted at/static) is replaced bybuild_static_assets(), which returns the two bundles as strings; they're served by two FastAPI routes captured in a closure. Why: the temp-dir files let two processes (or two installed versions) clobber each other's bundles, and causedPermissionErroron shared multi-user temp dirs on Linux. Nothing is written to disk anymore. The internal asset URLs changed:/static/styles.css→/_functoweb/static/styles.cssand/static/scripts.js→/_functoweb/static/scripts.js(internal URLs). - Returned-file cleanup is now opportunistic instead of timer-based — the per-process background daemon thread (
start_cleanup_timer) that sweptreturns_direveryreturns_lifetimeseconds has been removed. Cleanup now runs lazily on activity — when a file is saved (FileResponse) or downloaded — throttled by a.last_cleanupmarker file so it does real work at most once perreturns_lifetimewindow. Why: under the upcoming multi-processcreate_app()deployments, N workers would have spawned N redundant threads sweeping the same directory (and a library spawning daemon threads is undesirable in general); the marker approach is multi-process safe by being harmless (no locks — duplicate deletes are ignored). Observable contract change: expired files are now deleted on the next save/download after expiry rather than on a fixed timer; the boot-time cleanup inrun()still applies.returns_lifetimekeeps the same meaning. - Internals are now free of module-level config state — the upload/return directories, size limit and
stream_printsflag are no longer mutated onto module globals (save_file_handler.UPLOADS_DIR, etc.);run()resolves them as locals and passes them down explicitly through closures. Public behaviour is unchanged, but anyone who used to monkey-patchsave_file_handler.UPLOADS_DIR(or the other module globals) must pass the correspondingrun()keyword instead. - Default uploads and returned-files directories moved to the OS temp folder —
uploads_dirnow defaults to<os-temp-dir>/func_to_web_uploadsandreturns_dirto<os-temp-dir>/func_to_web_returned_files(resolved viatempfile.gettempdir()), instead of./uploadsand./returned_filesin the current working directory; transient files no longer pollute the project folder and the OS reclaims them automatically. Pass an explicituploads_dir=.../returns_dir=...to keep the previous behaviour root_pathpassed insidefastapi_configis now forwarded to FastAPI instead of being silently stripped — the internal filtering existed to protect a build-timeroot_paththat no longer exists (mounted apps get their prefix per request from Starlette;run()passesroot_paththrough to Uvicorn). Note that setting it there is normally unnecessary and can double prefixes if combined with mounting orrun(root_path=...).- Public namespace is now declared explicitly via
__all__infunc_to_web/types.py— previouslyfrom .types import *(no__all__) leaked every transitive import into the package root, sofunc_to_web.json,func_to_web.Path,func_to_web.BaseModel,func_to_web.dataclass,func_to_web.Any,func_to_web.Callableandfunc_to_web.model_validatorall existed as accidental, undocumented re-exports. These are no longer accessible from the package root (or viafrom func_to_web.types import *); import them from their real source (json,pathlib,pydantic) instead. The deliberate surface is unchanged:Field,Annotated,Literal,date,time,Params,FileResponseand all pytypeinput types (Color,Email,File,Slider, …) remain exported, and explicit imports likefrom func_to_web.types import Emailare unaffected. - Hardcoded Uvicorn deployment defaults removed
root_pathparameter removed fromrun()'s signature — now passed through to Uvicorn via**uvicorn_kwargs, which injects it into the ASGI scope.run(func, root_path="/tools")works exactly as before (no trailing slash; the previous auto-normalization is gone).- Sidebar navigation replaced by a "back to index" button — multi-function pages no longer render the left sidebar listing every function (and its mobile toggle/overlay). Each function page now shows only a small back button that returns to the index, which remains the single place that lists all functions. Simpler chrome, less code (the whole sidebar template, its JS and CSS are gone). Single-function apps are unchanged (no index, no button).
- FastAPI's Swagger UI / ReDoc / OpenAPI schema are off by default —
/docs,/redocand/openapi.jsonnow return404. The functions are exposed by name, not as typed OpenAPI operations, so that auto-generated schema misdescribed them;/docis the honest, machine-readable description. Re-enable viafastapi_config(e.g.create_app(func, fastapi_config={"openapi_url": "/openapi.json", "docs_url": "/docs"})). When mounted withcreate_app(), your host app's own docs are untouched. - Internal CSS/JS bundles are now browser-cacheable —
/_functoweb/static/styles.cssandscripts.jsare served withCache-Control: max-age=3600and a content-hashETag. Repeat loads within the window hit the browser cache (zero requests); after it expires the browser revalidates cheaply withIf-None-Matchand gets a304instead of re-downloading. A restart with new code changes the hash, so stale bundles invalidate themselves.
Removed
-
Authentication (
auth/secret_key). No compatibility shims remain: passing them now fails with a plainTypeError/Uvicorn error. Protect your app with a reverse proxy that handles auth (e.g. Nginx basic auth). -
front_dir/assets_dirfromrun(). Superseded by composition: mount your static site next to the tools with Starlette'sStaticFiles:host = FastAPI() host.mount("/tools", create_app(funcs)) host.mount("/", StaticFiles(directory="dist", html=True)) -
keep_uploadsparameter — removed fromrun(). Uploaded files were transient by design and are always cleaned up after the function finishes; persisting them is now done explicitly by moving the file out ofuploads_dir(e.g. withshutil.move()) before returning -
ActionTable— removed entirely, with no backward compatibility: returning one now falls through to the genericstr()text output, and theaction_tableSSE result type no longer exists. It coupled navigation to a table widget and had been marked experimental since its introduction. For standalone tools, functions remain directly reachable by URL with prefill; for CRUD apps with their own frontend, mountcreate_app()inside FastAPI and drive forms via URL prefill + embed mode. A first-class navigable output (Link) is planned after the API stabilizes. -
HiddenFunction— removed, with no backward compatibility: importing it now fails with anImportError, and every registered function always appears in the index and navigation. The flag had no audience: withrun()you want all your tools visible (the index is the UI), and when mounting viacreate_app()nobody looks at that index — if you need internal endpoints separated from visible tools, mount two apps (host.mount("/tools", create_app(visible))/host.mount("/api", create_app(internal))), which composes cleaner than a per-function flag. -
Function groups — passing a dict (or nested dicts) to
run()/create_app()to build collapsible, slug-prefixed navigation groups is gone.run()now accepts only a single function or a flat list; every function lives at its own top-level/<slug>. To separate sets of tools, mount severalcreate_app()apps under different FastAPI paths instead — it's clearer and removes a confusing nesting mechanism.
Fixed
- Internal URLs are now prefix-aware (work under a
root_path/ when mounted) — the HTML/JS emitted absolute, root-anchored URLs (/submit,/download/<id>,/_functoweb/static/..., navigation/index links). Behind a reverse proxy with a realroot_path, or underapp.mount("/tools", ...), those pointed at the domain root and broke styling, form submit, navigation and downloads. URLs are now derived per request fromrequest.scope["root_path"]and prepended to internal paths (templates receive aprefix; the frontend readswindow.__functoweb_prefix). The SSE payload is unchanged, as is/doc, the API contract andrun()'s public signature. This also paves the way for the mountablecreate_app()added in this release. workersandreloadpassed torun()are now rejected instead of silently ignored —run()hands the app instance to Uvicorn, and Uvicorn only spawns multiple workers, or runs its reload supervisor, when given an import string ("module:app"). Aworkers=N(N > 1) orreload=Truein**uvicorn_kwargstherefore had no effect: callers believed they had N processes / auto-reload while actually running a single, non-reloading process.run(func, workers=2+)andrun(func, reload=True)now raise an explicitValueError. Migration:workers=1(or omitting it) is unchanged; for real multiprocess or auto-reload, build the app withcreate_app()(added in this release) and serve it by import string withuvicorn/gunicorn(e.g.uvicorn mymodule:app --workers 4 --reload).
Internal
No observable behaviour change; dead-code and vestigial cleanup.
- Package reorganized by responsibility — the arbitrary root-vs-
core/split is gone. The public API (__init__,run,types,models) and shared foundations (normalization,constants,utils) sit at the package root; the rest moved into themed subpackages:serving/(server, routes, request handlers),rendering/(templates/builder,/doc),execution/(function call, result/table serialization, print capture) andfiles/(upload/return persistence). Public imports (from func_to_web import ...,from func_to_web.types import ...) are unchanged; only internal module paths moved. FunctionMetadatais now the single source of truth for multi-function apps — the parallelnavigation_datalist of{name, slug, description, url}dicts (built bybuild_navigation_structure()and stored onNormalizedInput) duplicated theFunctionMetadataobjects already initems, since every URL is just/<slug>. It's gone: templates, the index redirect and route registration readitemsdirectly, slug-uniqueness is validated innormalize_items(), and the now-trivial helpersbuild_navigation_structure(),register_navigation_routes()anddetect_input_type()were removed (the last two inlined). No behaviour change.- Removed a dead destructure binding in
zz-form.js—getOrCreateContainerwas pulled out ofwindow.functoweb.resultbut never used (onlyclearContainerandrenderResultare); it stays exported byresult-renderer.js, which uses it internally.
[1.0.2] - 2026-05-02
Fixed
ActionTablecell serialization for list / tuple / dict values — non-scalar cells were being rendered with Python'sstr(), producing invalid output like['34', 'aaa'](single quotes, not parseable as JSON)- Now serialized with
json.dumps, producing standard["34","aaa"]
- Now serialized with
ActionTablerow click sentNonecells as the literal string"None"in the URL — clicking a row produced URLs like?tags=None, which the prefill layer treated as a real value (activating optional toggles, failing to JSON-parse list fields)Noneis now preserved through serialization and the row-click handler omits the parameter entirely, matching the prefill contract (absent param == no value)
ActionTablerow click dropped embed mode when redirecting to another function — clicking a row in an embedded form (?__embed=1) navigated to the target function without the embed flag, so the destination rendered with full chrome (sidebar, theme toggle, opaque background) inside the iframe- The row-click handler now detects
__embed=1on the current page and propagates it to the redirect URL, keeping the whole action chain embedded
- The row-click handler now detects
- Single quotes / apostrophes in
Description(...)andPatternMessage(...)broke the form — param metadata is serialized to JSON and injected into the<pti-form params='...'>attribute, which is delimited by single quotes; any apostrophe in the text closed the attribute early and broke the rendered form- The serialized JSON is now HTML-escaped in the template (
params='{{ params_json | e }}'), so apostrophes (and",<,&) survive as entities and are decoded back to the original JSON when the<pti-form>web component reads the attribute
- The serialized JSON is now HTML-escaped in the template (
Changed
- Default
limit_max_requestsraised from 1000 to 10000 — the previous limit recycled the Uvicorn worker too aggressively for apps serving many static assets per page (e.g. image grids), causing the process to restart mid-session - Fixed pytypeinput and pytypeinputweb version numbers in dependencies — updated to the latest versions (1.0.2 and 1.0.3 respectively) to ensure compatibility with the new features and fixes in those libraries
- Uploads and returned-files directories are now created lazily —
uploads/andreturned_files/are no longer created at server startup; each directory is created on demand the first time a file is actually uploaded or returned, so apps with no file I/O never create them
[1.0.1] - 2026-04-28
Added
-
Custom frontend hosting via
front_dirandassets_dir—run()now accepts two new parameters to serve a custom frontend from the same processfront_dir: directory mounted at/frontwithhtml=Truefor SPA-style routing — drop a static site, landing page, or built React/Vue/Svelte bundle next to your Python functionsassets_dir: directory mounted at/assetsfor images, fonts, downloads or any static files referenced by your frontend or forms- Both are excluded from the auth middleware so static content is reachable without login
- Lets a single FuncToWeb process host the form UI, the API and a full custom frontend — no separate web server needed
-
/docendpoint — auto-generated, machine-readable API documentation- Every app now exposes
GET /docreturning a single plain-text document - Lists all registered functions (visible and hidden) with their parameters,
constraints, choices, defaults, and a working
curlexample for each - Parameters are emitted as JSON, making the doc directly parseable
- File parameters include an
upload_infoblock describing the multipart transport, field name, and whether multiple files are accepted - Dynamic dropdowns (
Dropdown(func)) are flagged with"dynamic": trueso consumers know the listed options are a snapshot, not an exhaustive set - URLs in examples use a
<base_url>placeholder, making the doc portable across local, proxied and production deployments - Designed to be consumed by humans, scripts, or AI agents calling the API without prior knowledge of the app
- Every app now exposes
-
Embed mode for iframe integration — append
?__embed=1to any function URL- Strips the sidebar, theme toggle, and outer chrome at runtime
- Forces a transparent background so the form blends into the parent page
- Removes the container's max-width, padding, shadow and border
- Lets you drop a FuncToWeb form into an existing web app via
<iframe>with no visual seams — combine with URL prefill (?param=value) for a fully pre-configured embedded form
[1.0.0] - 2026-04-15
Biggest release so far. The library has been rewritten from the ground up — most existing code works without changes or with very minor ones.
The biggest structural change is that FuncToWeb is now split into three independent libraries:
- pytypeinput — Analyzes Python type hints and extracts UI metadata. No web dependency.
- pytypeinputweb — Renders HTML forms from
pytypeinputmetadata. Use it in your own server. - func-to-web — The full stack.
This opens up a lot of new possibilities — for example, using FuncToWeb as a support layer inside an existing web app, exposing individual utility functions without building a full tool. There are other interesting approaches worth exploring that the docs cover.
A full re-read of the documentation is recommended.
This is a stable beta. I'll be actively fixing issues and improving things over the coming days.
[0.9.14] - 2026-04-01
Fixed
- Starlette compatibility issue
- Added explicit starlette<1.0.0
[0.9.13] - 2026-01-13
Added
- Multiple file upload improvements for
list[FileType]- New "+" button next to file input allows adding files from different folders
- Visual file list shows selected files with names, sizes, and remove buttons
- Supports all file types:
ImageFile,VideoFile,AudioFile,DataFile,TextFile,DocumentFile,File - Files can be selected from one folder, then more added from other folders
- Individual files can be removed before upload
- File list automatically hides when optional field is disabled
Changed
- Improved UX for file uploads with real-time feedback and preview
- Only frontend changes, fully backwards compatible with existing backend logic
[0.9.12] - 2026-01-11
Added
- New
Dropdown()type for dynamic dropdowns - cleaner, type-safe syntax for dropdowns with runtime-generated options- Use
Annotated[str, Dropdown(get_options)]instead ofLiteral[get_options] - Provides better IDE support and clearer intent
- Example:
- Use
from typing import Annotated
from func_to_web.types import Dropdown
def get_users():
return ['alice', 'bob', 'charlie']
def send_message(to: Annotated[str, Dropdown(get_users)]):
return f"Message sent to {to}"
- Works with
str,int,float, andbooltypes - Fully backwards compatible -
Literal[func]syntax still supported
[0.9.11] - 2026-01-07
Added
- Grouped functions feature: organize multiple functions into collapsible accordion groups
- Pass a dictionary to
run()with group names as keys and function lists as values - Example:
run({'Math': [add, multiply], 'Text': [upper, lower]}) - Groups display as accordion cards with badges showing function count
- Only one group can be open at a time for clean navigation
- Fully backwards compatible with existing single function and list modes
- Pass a dictionary to
[0.9.10] - 2026-01-01
Added
- VideoFile and AudioFile types for file uploads
VideoFile: Accepts common video formats (mp4, mov, avi, mkv, wmv, flv, webm, mpeg, mpg)AudioFile: Accepts common audio formats (mp3, wav, aac, flac, ogg, m4a)
- Updated ImageFile type to include additional formats (raw, psd)
- FileResponse now accepts either binary data or file path
FileResponse(data=bytes, filename="file.ext")- for in-memory filesFileResponse(path="/path/to/file", filename="file.ext")- for existing files on disk- Files specified by path are copied to returns_dir for consistent management
- Both approaches result in automatic 1-hour cleanup
Changed
- Changed default title of index page from "Function Tools" to "Menu"
[0.9.9] - 2025-12-23
Performance
- Non-blocking Execution: Standard Python functions (
def) are now automatically executed in a thread pool. This prevents CPU-heavy tasks from blocking the main event loop. - Async Disk I/O: Offloaded
FileResponseprocessing and disk writing to background threads.- Generating and saving large files (GB+) no longer freezes the server.
- The UI remains responsive for other users while files are being written to disk.
- Improved Concurrency: The server can now handle multiple simultaneous heavy requests (calculations or downloads) without queue blocking.
[0.9.8] - 2025-12-21
Changed
- FileResponse Filename Limit: 150-character maximum (Pydantic validated)
Security
- Filename Sanitization: User-uploaded files sanitized against directory traversal, reserved names, and special characters
- Format:
{sanitized_name}_{32char_uuid}.{ext} - 100-char limit on user portion, ~143 total length
- Preserves original name for identification
- Format:
Fixed
- File Lists: Fixed bug where
list[File]would fail with JSON parsing error- Backend now uses
form_data.getlist()to properly group uploaded files validate_list_param()accepts pre-processed lists in addition to JSON strings
- Backend now uses
[0.9.7] - 2025-12-10
Philosophy Change
Version 0.9.6 introduced SQLite for file tracking, blocked multiple workers, and added complex configuration. This was overengineered. func-to-web should be simple, fast, and reliable.
0.9.7 returns to simplicity with filesystem-based tracking, multiple workers support, and sensible defaults.
Removed
-
SQLite Database
- No more database files or locks
- File metadata encoded directly in filenames
- Format:
{uuid}___{timestamp}___{filename}
-
Parameters Removed
db_location- no longer neededcleanup_hours- now hardcoded to 1 hour
-
Workers Limitation
workers > 1no longer blocked- Scale vertically without restrictions
Added
-
Automatic Upload Cleanup
- New parameter:
auto_delete_uploads(default:True) - Uploaded files deleted after function completes
- Disable with
auto_delete_uploads=Falseif needed
- New parameter:
-
Directory Configuration
- New parameter:
uploads_dir(default:"./uploads") - New parameter:
returns_dir(default:"./returned_files")
- New parameter:
Changed
-
File Retention
- Returned files deleted 1 hour after creation (hardcoded)
- No download tracking needed
- Cleanup runs every hour automatically
-
Multiple Workers
- Now supported like any other Uvicorn option
- Each worker runs independent cleanup
- File operations are atomic, no conflicts
-
Architecture
- Removed
db_manager.pymodule - Simplified
file_handler.py - Faster startup (no database initialization)
- Removed
Fixed
- Database lock errors eliminated
- Race conditions eliminated
- Improved startup performance
Documentation
- Updated all docs to remove SQLite references
- Removed
db_locationandcleanup_hoursfrom examples - Added
auto_delete_uploadsdocumentation - Updated API reference (removed
db_manager)
Migration from 0.9.6
Before:
run(my_function, db_location="/data", cleanup_hours=48)
After:
run(my_function, uploads_dir="/data/uploads", returns_dir="/data/returns")
# Files now expire after 1 hour (hardcoded)
Breaking Changes:
db_locationremoved (usereturns_dir)cleanup_hoursremoved (hardcoded to 1 hour)func_to_web.dbno longer created
Non-Breaking:
workersparameter now supported- All other parameters unchanged
Summary
0.9.6 was overengineered. 0.9.7 is simple again: no database, automatic cleanup, multiple workers supported. Filesystem operations are fast, atomic, and sufficient.
[0.9.6] - 2025-12-10
Added
-
Automatic Periodic Cleanup: Files are now automatically cleaned up every hour while the server runs.
- No need to restart the server for cleanup to occur
- Cleanup task runs in background every 3600 seconds (1 hour)
- Files older than
cleanup_hoursare removed from both disk and database - Configurable via
cleanup_hoursparameter (default: 24 hours) - Set
cleanup_hours=0to disable periodic cleanup
-
Thread-Safe File Cleanup: Implemented threading locks to prevent race conditions during concurrent file cleanup operations.
- Per-file locks ensure only one thread can clean up a specific file at a time
- Lock registry automatically cleaned up after operations complete
- Prevents "file not found" errors when multiple threads/requests attempt cleanup simultaneously
- Safe for high-concurrency environments with async I/O
-
Database Health Monitoring: Added automatic monitoring for file registry size.
- Displays warning if database contains >10,000 file references on startup
- Helps identify when manual cleanup or configuration changes are needed
- New
get_file_count()function indb_managermodule
-
Enhanced Security: Added UUID validation for file download endpoints.
- Validates file IDs match UUID v4 format before database queries
- Returns 400 Bad Request for malformed file IDs
- Additional layer of protection against injection attempts
Changed
-
Workers Limitation: Multiple workers (
workers > 1) are now explicitly blocked and will raise a clear error.- Prevents SQLite database corruption from concurrent writes across processes
- Displays educational error message with scaling alternatives
- Recommends running multiple instances with Nginx instead
- Single worker can handle 500-1,000 req/s with async I/O (sufficient for most teams)
-
Database Location Validation: Improved validation and path handling for
db_locationparameter.- Automatically creates directories if path is a directory
- Validates parent directory exists for file paths
- Raises clear error messages with actionable guidance
- Better support for custom database locations
-
Database Connection Timeouts: Added 5-second timeout to SQLite connections to prevent deadlocks.
Fixed
-
FileNotFoundError Handling: Improved error handling when uploaded or returned files are manually deleted from disk.
- Auto-healing: broken database references are cleaned up automatically
- Returns "File expired" instead of internal server error
- Graceful degradation when files are missing
-
Lock Cleanup: Threading locks are now properly cleaned up in
finallyblocks.- Prevents lock registry from growing indefinitely
- Eliminates potential memory leaks in long-running processes
Documentation
-
File Upload Cleanup Clarification: Updated
files.mdto clearly explain OS cleanup behavior.- Added comparison table for Linux/macOS/Windows automatic cleanup
- Warning for Windows users about potential file accumulation
- Three cleanup strategies with code examples (OS, manual, in-memory)
- Clear distinction between uploaded files (not auto-cleaned) and returned files (auto-cleaned)
-
Scaling Guidelines: Added comprehensive scaling section to
server-configuration.md.- Explains why multiple workers aren't supported (SQLite limitations)
- Documents single worker performance capabilities (500-1,000 req/s)
- Provides step-by-step guide for horizontal scaling with multiple instances
- Nginx sticky sessions configuration for load balancing
- Enterprise alternatives for >1,000 concurrent users
-
Updated API Documentation: All docstrings revised for consistency.
- No inline comments (per style guide)
- Comprehensive function/class documentation
- Clear parameter descriptions with examples
Refactored
-
Modular Architecture: Complete code reorganization into specialized modules for better maintainability.
server.py: Main server configuration and entry pointroutes.py: Routing setup and request handlingfile_handler.py: File upload/download operations with thread-safe cleanupdb_manager.py: SQLite database operations for file trackingauth.py: Authentication middleware and session managementanalyze_function.py: Function signature analysis and metadata extractionvalidate_params.py: Form data validation and type conversionbuild_form_fields.py: HTML form field generation from type hintsprocess_result.py: Result processing for different output typescheck_return_is_table.py: Table format detection and conversiontypes.py: Type definitions and custom types (Color, Email, File types)__init__.py: Clean public API with minimal exports (run, type helpers)
-
Benefits:
- Separation of concerns: Each module has a single, clear responsibility
- Easier testing: Modules can be tested independently
- Better code navigation: Find functionality quickly by module name
- Reduced coupling: Clear interfaces between components
- Future-proof: Easy to extend without touching unrelated code
[0.9.5] - 2025-12-09
Added
- New Generic
FileType: Added support for a genericFiletype hint.- Use
from func_to_web.types import Fileto accept uploaded files of any extension.
- Use
- Expanded File Extensions: Significantly broadened the list of supported formats for specific file types.
[0.9.4] - 2025-12-08
Added
- Python Enum Support: Full support for Python
Enumtypes as dropdown menus.- Use standard Python enums as type hints:
def func(theme: Theme) - Supports
str,int, andfloatenum values - Automatic conversion from form values back to Enum members
- Your function receives the actual Enum member (e.g.,
Theme.LIGHT), not just the string value - Access both
.nameand.valueproperties in your function - Optional enums with
Theme | Nonesyntax - Compatible with all enum features (methods, properties, iteration)
- Add tests covering enum handling, conversion, and edge cases
- Use standard Python enums as type hints:
Benefits
- Type Safety: Full IDE autocomplete and type checking
- Reusability: Define enum once, use across multiple functions
- Rich Semantics: Access both enum name and value, add custom methods
- Clean Code: No repetition of
Literal['option1', 'option2']in every function signature
[0.9.3] - 2025-11-30
Fixed
- Async Function Support: Fixed an issue where passing an
async deffunction displayed a<coroutine object>instead of the result.- The library now automatically detects
asyncfunctions andawaitsthem properly. - Enables seamless integration with async libraries (e.g.,
httpx,tortoise-orm,motor).
- The library now automatically detects
[0.9.2] - 2025-11-25
Added
- Built-in Authentication: Robust, stateless authentication system.
- Enable simply by passing a dictionary
auth={"username": "password"}to therun()function. - Architecture based on Signed Cookies (no database required).
- Includes protection against Timing Attacks (
secrets.compare_digest) and CSRF (SameSite='Lax').
- Enable simply by passing a dictionary
- Session Management: New
secret_keyargument inrun()to control session persistence across server restarts. - Login UI:
- Dedicated, modern login page that automatically inherits the application's theme (Light/Dark).
- Responsive design matching the core library aesthetics.
- Logout Functionality: New logout button in the header navigation (automatically appears when auth is enabled).
Changed
- Dependencies: Added
itsdangerousto required packages (essential for session signing). - Templates: Updated
basetemplates to handle conditional rendering based on authentication state (has_authflag).
[0.9.1] - 2025-11-24
Added
- Reverse Proxy Support: New
root_pathargument inrun()to properly handle deployments behind Nginx, Traefik, or Docker containers with path prefixes. - Advanced Server Configuration: Any extra keyword arguments passed to
run()(**kwargs) are now forwarded directly to Uvicorn.- Enables SSL/HTTPS support (
ssl_keyfile,ssl_certfile). - Allows performance tuning (
workers,limit_max_requests,timeout_keep_alive).
- Enables SSL/HTTPS support (
- Custom API Metadata: New
fastapi_configdictionary argument to customize the underlying FastAPI application (e.g., changing the API title, version, or disabling swagger docs).
[0.9.0] - 2025-11-24
Added
- Table Rendering: Automatic HTML table generation from multiple data formats
list[dict]- Headers extracted from dictionary keyslist[tuple]- Auto-generated headers (Column 1, Column 2, etc.)- Pandas DataFrame - Direct support with column names as headers
- NumPy 2D Arrays - Renders with auto-generated headers
- Polars DataFrame - Native support with column names
- Tables can be combined with other outputs in tuples/lists
- Zebra striping for better readability
Changed
-
Form Container: Added horizontal resize capability on desktop (≥1025px)
- Default width: 500px
- Resizable from 400px to 1400px by dragging the edge
- Disabled on tablets and mobile devices
- Maintains responsive behavior with proper padding
-
Result Display: Enhanced UI/UX for output presentation
- Replaced text-based "Copy" button with a subtle, floating SVG icon in the top-right
- Optimized vertical alignment to perfectly center text relative to the button
- Removed enclosing quotes from string results (both in display and clipboard)
- Improved button state logic to handle rapid clicks and timeouts robustly
[0.8.1] - 2025-11-24
Added
- Multiple Outputs: Functions can now return tuples or lists to display multiple outputs simultaneously
- Combine text, images, plots, and file downloads in a single response
- Example:
return ("Analysis complete", processed_image, plot_figure, report_file) - Nested tuples/lists are not supported (validation with clear error message)
- Each output type rendered in its own container with proper spacing
Changed
- Output Processing: Enhanced
process_result()to handle tuple/list returns recursively - Response Format: Backend now supports
result_type: 'multiple'with nested outputs array - Frontend Rendering: New
createMultipleOutputs()function in builders.js for recursive rendering
[0.8.0] - 2025-11-23
Added
- Back Button Navigation: Added back button on form pages to return to tools index
Changed
-
Dark/Light Theme Toggle: Complete redesign with SVG icons
- Replaced emoji icons with SVG moon/sun icons for better alignment and aesthetics
-
Field Label Formatting: Labels now automatically replace underscores with spaces
user_namedisplays as "User Name"api_urldisplays as "Api Url"
-
Function Description Styling: Improved appearance of function docstrings
Fixed
-
Button Alignment: Fixed vertical misalignment between theme toggle and back button
- Resolved CSS inheritance issue where global
buttonselector was addingmargin-top: 0.5rem
- Resolved CSS inheritance issue where global
-
Number Input Controls (Dark Mode): Fixed visibility of increment/decrement arrows in dark mode
- Applied
color-scheme: darkfor native dark mode styling - Arrows now properly visible against dark backgrounds
- Applied
-
Simplified optional field interface:
- Removed
optionalbadge labels from field names for cleaner design - Removed "Enable field" text label next to toggle switches
- Toggle switches now self-explanatory without redundant text
- Cleaner, more minimal form appearance
- Removed
Improved
- CSS Architecture: Enhanced maintainability and reduced inheritance issues
- Removed redundant CSS properties
- Cleaner separation between component styles
- Example Code: Better examples in /examples folder with improved comments
- Update examples images: Regenerated example images
[0.7.6] - 2025-11-14
Fixed
- PyPI README: Fixed missing README.md display on PyPI package page
- Added
long_descriptionandlong_description_content_typeto package metadata
- Added
[0.7.5] - 2025-11-14
Fixed
- Long text output handling: Fixed layout overflow when functions return long strings (e.g., 100+ character passwords)
- Added word-wrapping and proper text overflow handling in result containers
- Applied
word-break: break-allandoverflow-wrap: break-wordto prevent layout breaking - Improved responsive behavior for long outputs on mobile devices
[0.7.4] - 2025-10-26
Added
- Function Descriptions: Functions with docstrings now display their description below the title in the web UI
- Extracted using
inspect.getdoc()for clean formatting - Centered text with improved contrast in dark mode
- Styled with left border accent matching the theme
- Extracted using
Changed
- Code Refactoring: Reduced code duplication in
run.py- Created
create_response_with_files()helper function for file download responses - Created
handle_form_submission()async function to consolidate form processing logic - Eliminated duplicate code between single and multiple function modes
- Improved maintainability and consistency across endpoints
- Created
[0.7.3] - 2025-10-25
Changed
- Complete Documentation Rewrite: Restructured entire documentation using MkDocs Material for better navigation and user experience
- Organized into clear categories: Input Types, Types Constraints, Output Types, and Other Features
- Added dedicated pages for each feature with visual examples and code snippets
- Improved progressive learning flow with "Next Steps" navigation
- Enhanced README with direct links to all major documentation sections
- Better mobile responsiveness and dark mode support
[0.7.2] - 2025-10-18
Added
- Auto-focus First Field: Cursor automatically focuses on the first input field when the page loads, improving keyboard navigation
- Keyboard Shortcuts:
Ctrl+Enter(orCmd+Enteron Mac) to submit the form from any input field- Works only when submit button is not disabled
- Copy to Clipboard: JSON results now include a "Copy" button to copy output to clipboard
- Toast Notifications: Elegant toast messages for user feedback (e.g., "✓ Copied to clipboard!")
- Auto-dismisses after 2 seconds
- Adapts to light/dark themes using CSS variables
- Fallback for older browsers without Clipboard API
Changed
- Frontend Refactoring: Complete restructuring of JavaScript codebase for improved maintainability and code organization
- Extracted pure utility functions to
utils.js - Separated DOM construction logic to
builders.js - Isolated validation logic to
validators.js - Added DOM manipulation helpers in
main.jsfor cleaner state management - Reduced cognitive load with single-responsibility functions
- Improved code reusability and testability
- Extracted pure utility functions to
[0.7.1] - 2025-10-17
Fixed
- Optional List Fields: Hide add (+) and remove (-) buttons when optional list fields are disabled
- Error Messages on Disabled Fields: Clear error messages when fields are disabled
- Initial State Consistency: Fixed inconsistent behavior between page load and toggle interactions
- Minimum List Items: Lists with minimum item requirements now auto-create all required items
[0.7.0] - 2025-10-13
Added
- Dark Mode: Toggle between light and dark themes with persistent preference
- Floating theme toggle button (🌙/☀️) in top-right corner
- Theme preference saved in localStorage
- Smooth transitions between themes
- Optimized color scheme for dark mode with proper contrast
- Works on both form and index pages
- Animated toggle button with hover effects
- Mobile-responsive button sizing
[0.6.0] - 2025-10-13
Added
- File Download Support: Return files from functions with automatic download buttons
- Return single file:
FileResponse(data=bytes, filename="file.txt") - Return multiple files:
[FileResponse(...), FileResponse(...)] - Streaming downloads: Efficient handling of large files (GB+) without memory issues
- Works with any file type: PDF, Excel, ZIP, images, binary data, etc.
- No size limits: Uses temporary files and streaming like file uploads
- Clean UI: File list with individual download buttons
- Automatic cleanup: Temp files deleted after download
- Example:
- Return single file:
def create_report(name: str):
pdf_bytes = generate_pdf(name)
return FileResponse(data=pdf_bytes, filename="report.pdf")
[0.5.0] - 2025-10-12
Added
- List Support: Full support for list parameters with dynamic add/remove items
- Syntax:
list[int],list[str],list[float],list[Color],list[ImageFile], etc. - Works with all basic types:
int,float,str,bool,date,time - Works with special types:
Color,Email,ImageFile,DataFile, etc. - Item-level constraints:
list[Annotated[int, Field(ge=1, le=100)]] - List-level constraints:
Annotated[list[int], Field(min_length=2, max_length=10)] - Combined constraints:
Annotated[list[Annotated[int, Field(ge=0)]], Field(min_length=2)] - Dynamic UI: Add/remove buttons to manage list items
- Optional lists:
list[str] | Noneorlist[str] | OptionalDisabled - Default values:
list[str] = ["hello", "world"] - Default behavior: Lists without explicit values default to
None(not[])list[int]→default = Nonelist[int] = []→default = None(empty lists converted toNone)list[int] = [1, 2]→default = [1, 2](only non-empty lists preserved)
- Item-level validation: Each list item validates against type constraints
- List-level validation: Validates
min_lengthandmax_lengthconstraints - Visual feedback: Individual error messages per list item
- Empty/whitespace values automatically filtered out
- Syntax:
[0.4.5] - 2025-10-12
Fixed
- Color Picker UI Bug: Fixed color picker not opening when clicking on color preview box
- Removed CSS properties that prevented programmatic clicks (
pointer-events: none, extreme positioning) - Simplified hidden color input positioning using
width: 0,height: 0, andz-index: -1 - Maintained visual appearance while ensuring browser can open native color picker
- Color picker now properly opens on preview click for both regular and optional fields
- Removed CSS properties that prevented programmatic clicks (
[0.4.4] - 2025-10-11
Added
- Explicit Optional Control: New
OptionalEnabledandOptionalDisabledmarkers for precise control over optional field initial stateType | OptionalEnabled: Field always starts enabled, regardless of default valueType | OptionalDisabled: Field always starts disabled, even with default value- Explicit markers override automatic behavior (presence of default value)
- Works with all types: basic types, special types (Color, Email), constraints, and Literals
- Backwards compatible: standard
Type | Nonesyntax continues working with automatic behavior
- Test Suite for Optional Markers: 44 tests covering explicit optional control
- All basic types with both markers (int, str, float, bool, date, time)
- Special types (Color, Email) with markers
- Constraints combined with markers
- Default value override behavior
- Mixed usage (automatic + explicit in same function)
- Edge cases (markers with
= None) - 316 total tests across all modules (130 + 88 + 88)
Changed
- ParamInfo dataclass: Added
optional_enabledfield to store initial toggle state - analyze(): Enhanced Union type detection to identify OptionalEnabled/OptionalDisabled markers
- types.py: Added marker classes and type aliases for explicit optional control
[0.4.3] - 2025-10-10
Added
- Test Suite for build_form_fields(): 88 tests covering HTML field generation, constraint extraction, and edge cases
- All field types: text, number, checkbox, select, date, time, color, email, file
- Format conversions: date → ISO, time → HH:MM
- Constraint handling: min/max/step for numbers, minlength/maxlength for strings
- Dynamic Literal re-execution and error cases (empty lists, mixed types)
- Edge cases: Unicode (😀🚀), negative/large values (1e100), leap years, boundary constraints
- Complex scenarios: 9+ parameter functions, mixed optional states, order preservation
- All tests pass in 0.58s
Changed
- Code Refactoring: Extracted
build_form_fields()to dedicated module- New module:
build_form_fields.pywith pattern constants - New module:
process_result.pyfor result handling - New module for custom patterns:
custom_pydantic_types.py - Three core modules:
analyze_function.py,validate_params.py,build_form_fields.py - 272 total tests across all modules (96 + 88 + 88)
- New module:
[0.4.2] - 2025-10-10
Added
- Test Suite for validate_params(): 88 tests covering type conversion, validation, and edge cases
- Type conversions: strings → int/float/bool/date/time
- Constraint validation: numeric bounds, string length, pattern matching
- Optional toggle behavior, checkbox handling, hex color expansion (#abc → #aabbcc)
- Edge cases: negative numbers, scientific notation (1.5e10), Unicode (Héllo 世界 🌍), leap years
- All tests pass in 0.53s
Changed
- Code Refactoring: Extracted
validate_params()to dedicated module- New module:
validate_params.py - 184 total tests (96 + 88)
- New module:
[0.4.1] - 2025-10-10
Added
- Test Suite for analyze(): 96 tests covering function signature analysis
- All types, constraints, special types (Color, Email, Files), Literals, optionals
- Error cases: unsupported types, invalid defaults, type mismatches
Fixed
- Default Value Type Validation: Added type checking for defaults in
analyze()
Changed
- Code Refactoring: Extracted
analyze()andParamInfotoanalyze_function.py
[0.4.0] - 2025-10-09
Added
- Optional Parameters: Full
Type | Nonesupport with visual toggle switches- Fields with defaults start enabled, without defaults start disabled
- Works with all types and constraints
Fixed
- Dynamic Literals: Single string returns no longer split into characters
- Dynamic Literal Validation: Skip validation since options can change between render and submit
Changed
- Frontend Refactoring: Separated CSS/JS from templates
form.html→ clean template onlyform.js→ all JavaScript logicstyles.css→ all styling
[0.3.0] - 2025-10-08
Added
- Upload Progress: Real-time progress bar, file size display, status messages
Fixed
- Debug Mode: Fixed uvicorn crash with asyncio debugger
Changed
- Upload Performance: 8MB chunk streaming, ~237 MB/s on localhost
- Replaced
fetch()withXMLHttpRequestfor progress tracking
- Replaced
[0.2.0] - 2025-10-07
Added
- Dynamic Dropdowns: Functions in
Literalgenerate options at runtime
[0.1.0] - 2025-10-05
Added
- Initial release with basic types, files, validation, images/plots, multi-function support