router_of()

August 8, 2026 · View on GitHub

Turns a space of functions into an APIRouter that you can mount in any FastAPI host application.

router_of(
    fns,
    *,
    title: str | None = None,
    capture_prints: bool | None = None,
    max_upload_bytes: int | None = None,
    pending_ttl: int | timedelta | None = 3600,
    returns_ttl: int | timedelta | None = 3600,
    uploads_dir: str | Path | None = None,
    returns_dir: str | Path | None = None,
    theme: Theme = "system",
) -> APIRouter
from fastapi import FastAPI

from func_to_web import router_of


app = FastAPI()
app.include_router(router_of([add, divide]), prefix="/tools")

The prefix belongs to include_router(), not to the router: all of the router's routes are relative, so the space works under any mounted prefix.

What it accepts

fns is the space, in any of its forms: a function, a WebFunction, an iterable mixing both (list, tuple, generator), or an already prepared WebFunctions.

Plain functions are wrapped in a WebFunction, which derives their name, description and slug; a WebFunction is taken as it is. The order of the iterable is preserved: it is the order of /doc and of the index.

InputError
[]ValueError: at least one function is required
None, 3, "add", {"a": add}TypeError: entries must be callables or WebFunction instances
[add, 3]the same TypeError
two entries with the same slugValueError: two functions share the slug 'add'
a function no slug can be derived fromValueError: cannot derive a valid slug…

A str and a dict are iterables, so they do get traversed; their elements — characters or keys — are not functions, and the error is the same TypeError. Errors in the definition itself are those of WebFunction, which is built here.

What routes it registers

GET  /{slug}/                the page of the function
POST /{slug}/invoke          its execution
POST /{slug}/invoke-stream   the same execution, streamed over SSE
GET  /doc                    the document of the space
GET  /static/{path}          the shared assets
POST /upload                 only if some function has a file field
GET  /returns/{ref}          only if some function declares a Download

The first five are always there; invoke-stream also exists for functions that print nothing. The last two are registered only when some function needs them, so a space without input files does not expose /upload, and one without output files does not expose /returns. That is why four slugs are reserved: doc, static, upload and returns. All four are always rejected, even in a space that registers neither of the two conditional routes:

router_of([WebFunction(add, slug="upload")])
# ValueError: slug 'upload' is reserved

See http.md, streaming.md, api-docs.md, static-assets.md, files.md and outputs.md.

The arguments

title names the space in /doc and in the index. If you do not set it, the title is "FuncToWeb"; whatever you pass is normalized with strip(). An already prepared WebFunctions carries its own title, so passing a title again is an error:

router_of(space, title="Other")
# TypeError: the prepared space already carries its title;
# set the title when creating WebFunctions

capture_prints decides, for the whole space, whether what the functions print is streamed over /invoke-stream. If you do not set it, output is captured; a value declared by a WebFunction overrides it.

max_upload_bytes is the maximum size, in bytes, of each file accepted by the /upload route of this router. If you do not set it, FuncToWeb imposes no limit. It is validated when the router is built. See files.md.

pending_ttl is how long an uploaded file survives without being used: an upload nothing ever promotes is deleted once it is older than this. It takes seconds as an int or a datetime.timedelta, and None turns the whole cycle off, so /upload publishes definitively.

returns_ttl is the same setting for the other direction: how long a file the function returned survives in the returns directory before it is deleted. It is simpler, because there is nothing to promote — a download is fetched or it is not, and neither makes it permanent, so there is one state and one date. With None there is no date in the name of the returned file either.

Both take the same values, are validated when the router is built, like the limit, and raise the same two errors, each with its own name in them:

ValueWhat it means for either setting
3600the default: one hour
timedelta(days=1)normalized to seconds
Noneno expiry, and no sweeping of that directory
TypeError: pending_ttl must be int, timedelta or None
ValueError: pending_ttl must be greater than zero

They are two settings, not one, so a space can hold its uploads for a week and still hand out downloads that live an hour. Why both default to one hour is in design/router.md.

uploads_dir and returns_dir say where each of those two directories is. Neither is required: uploads go to the user's data directory of the platform (%LOCALAPPDATA%, ~/Library/Application Support or $XDG_DATA_HOME) and returned files to a folder of their own inside the system temporary directory. Both can also be set from outside the code, which is what a deployment usually wants:

FUNCTOWEB_UPLOADS_DIR    where the uploaded files are stored
FUNCTOWEB_RETURNS_DIR    where the returned files are stored

The argument wins over the variable, and the variable over the default, so naming a directory in the call means it and leaving it out lets the environment decide:

router_of(functions, uploads_dir="/srv/functoweb/uploads")

Whichever of the three wins is checked when the router is built: it is made absolute, it is created if it is missing, and whatever stops that from happening —a permission, a full disk, a name already taken by a file— is raised there. The first request never meets a directory this process cannot write to.

TypeError: uploads_dir must be str, Path or None
ValueError: returns_dir is not a directory: /etc/hosts

A relative path is resolved against the working directory of the process, which is not where the code lives; an absolute one is the honest way to write it. And the two directories are independent: moving the uploads says nothing about the returns.

One process, one policy

One process is one storage directory, so it is one policy: what the first router settles governs everything afterwards —where /upload publishes, what the resolver expires and what the single sweeping thread deletes in either directory— for every space mounted in that process.

Settled bySettings
the first router with file fieldsuploads_dir and pending_ttl
the first router with a Downloadreturns_dir and returns_ttl

A later router asking for a different one does not get it, and is told so rather than left guessing:

router_of(reports, pending_ttl=3600)
router_of(invoices, pending_ttl=None)
# UserWarning: pending_ttl=None is ignored: this process already stores its
# files with pending_ttl=3600, settled by an earlier router. Storage is one
# policy per process, not one per router.

The warning reads the same for returns_ttl, for uploads_dir and for returns_dir, with the name of the setting changed: it is one sentence for every storage setting, not one per setting. Two different directories need two processes, exactly as two TTLs do, because the 409, the promotion and the sweep all work on what the process settled. See files.md, outputs.md and limitations.md.

Theme

theme decides how every page of the space looks. There are exactly three values:

"system"   follows the operating system preference   (default)
"light"    light, always
"dark"     dark, always

There is no coercion: "Dark", "auto", "light ", None or a bool fail when the router is built, not on the first request.

router_of([add, divide], theme="Dark")
# ValueError: theme must be one of system, light, dark; got 'Dark'

router_of([add, divide], theme=None)
# TypeError: theme must be str

What it does is write the attribute on the <html> element the server serves:

<html>                        theme="system"
<html data-pth-theme="light"> theme="light"
<html data-pth-theme="dark">  theme="dark"

It goes in the initial markup, so the theme is resolved before the first paint and there is no flicker.

The theme belongs to the space, not to each function: all of its pages share a single theme, and opening a form with prefill or hidden preserves it. Two themes need two routers.

See static-assets.md and design/router.md.

Everything is prepared when the router is built

Each entry compiles its metadata, its schema, its plan and its base HTML at that point, not per request. An inconsistent definition fails at startup, before the first request is accepted. Building the router does not call any function: it only reads their signatures.

What it does not do

  • It does not create the application or start a server: that is run().
  • It does not decide the mounted prefix.
  • It does not register an index at /. That page is built by run(); a mounted router exposes the functions, not a menu.
  • It does not provide authentication, middleware or CORS: whatever applies comes from the host application. See security.md.

Related: web-function.md, run.md, sdk.md, api-docs.md.