Skip to content

API Reference

func_to_web

run(func_or_list, host='0.0.0.0', port=8000, auth=None, secret_key=None, uploads_dir='./uploads', returns_dir='./returned_files', auto_delete_uploads=True, template_dir=None, root_path='', fastapi_config=None, **kwargs)

Generate and run a web UI for one or more Python functions.

Single function mode: Creates a form at root (/) for the function. Multiple functions mode: Creates an index page with links to individual function forms. Grouped functions mode: Creates an index page with grouped sections of functions.

Parameters:

Name Type Description Default
func_or_list Callable[..., Any] | list[Callable[..., Any]] | dict[str, list[Callable[..., Any]]]

A single function, list of functions, or dict of {group_name: [functions]}.

required
host str

Server host address (default: "0.0.0.0").

'0.0.0.0'
port int

Server port (default: 8000).

8000
auth dict[str, str] | None

Optional dictionary of {username: password} for authentication.

None
secret_key str | None

Secret key for session signing (required if auth is used). If None, a random one is generated on startup.

None
uploads_dir str | Path

Directory for uploaded files (default: "./uploads").

'./uploads'
returns_dir str | Path

Directory for returned files (default: "./returned_files").

'./returned_files'
auto_delete_uploads bool

If True, delete uploaded files after processing (default: True).

True
template_dir str | Path | None

Optional custom template directory.

None
root_path str

Prefix for the API path (useful for reverse proxies).

''
fastapi_config dict[str, Any] | None

Optional dictionary with extra arguments for FastAPI app (e.g. {'title': 'My App', 'version': '1.0.0'}).

None
**kwargs

Extra options passed directly to uvicorn.Config. Examples: ssl_keyfile, ssl_certfile, log_level, workers.

{}

Raises:

Type Description
FileNotFoundError

If template directory doesn't exist.

TypeError

If function parameters use unsupported types.

Examples:

Single function: run(my_function)

Multiple functions: run([func1, func2, func3])

Grouped functions: run({ 'Math': [add, subtract, multiply], 'Text': [uppercase, lowercase] })

Notes
  • Returned files are automatically deleted 1 hour after creation (hardcoded).
  • Cleanup runs on startup and then every hour while server is running.
  • Multiple workers are supported (each worker runs its own cleanup task).
Source code in func_to_web\server.py
def run(
    func_or_list: Callable[..., Any] | list[Callable[..., Any]] | dict[str, list[Callable[..., Any]]], 
    host: str = "0.0.0.0", 
    port: int = 8000, 
    auth: dict[str, str] | None = None,
    secret_key: str | None = None,
    uploads_dir: str | Path = "./uploads",
    returns_dir: str | Path = "./returned_files",
    auto_delete_uploads: bool = True,
    template_dir: str | Path | None = None,
    root_path: str = "",
    fastapi_config: dict[str, Any] | None = None,
    **kwargs
) -> None:
    """Generate and run a web UI for one or more Python functions.

    Single function mode: Creates a form at root (/) for the function.
    Multiple functions mode: Creates an index page with links to individual function forms.
    Grouped functions mode: Creates an index page with grouped sections of functions.

    Args:
        func_or_list: A single function, list of functions, or dict of {group_name: [functions]}.
        host: Server host address (default: "0.0.0.0").
        port: Server port (default: 8000).
        auth: Optional dictionary of {username: password} for authentication.
        secret_key: Secret key for session signing (required if auth is used). 
                    If None, a random one is generated on startup.
        uploads_dir: Directory for uploaded files (default: "./uploads").
        returns_dir: Directory for returned files (default: "./returned_files").
        auto_delete_uploads: If True, delete uploaded files after processing (default: True).
        template_dir: Optional custom template directory.
        root_path: Prefix for the API path (useful for reverse proxies).
        fastapi_config: Optional dictionary with extra arguments for FastAPI app 
                        (e.g. {'title': 'My App', 'version': '1.0.0'}).
        **kwargs: Extra options passed directly to `uvicorn.Config`.
                  Examples: `ssl_keyfile`, `ssl_certfile`, `log_level`, `workers`.

    Raises:
        FileNotFoundError: If template directory doesn't exist.
        TypeError: If function parameters use unsupported types.

    Examples:
        Single function:
            run(my_function)

        Multiple functions:
            run([func1, func2, func3])

        Grouped functions:
            run({
                'Math': [add, subtract, multiply],
                'Text': [uppercase, lowercase]
            })

    Notes:
        - Returned files are automatically deleted 1 hour after creation (hardcoded).
        - Cleanup runs on startup and then every hour while server is running.
        - Multiple workers are supported (each worker runs its own cleanup task).
    """

    uploads_path = Path(uploads_dir)
    returns_path = Path(returns_dir)
    uploads_path.mkdir(parents=True, exist_ok=True)
    returns_path.mkdir(parents=True, exist_ok=True)

    config.UPLOADS_DIR = uploads_path
    config.RETURNS_DIR = returns_path
    config.AUTO_DELETE_UPLOADS = auto_delete_uploads

    is_grouped = isinstance(func_or_list, dict)
    is_single = not isinstance(func_or_list, (list, dict))

    if is_grouped:
        grouped_funcs = func_or_list
        funcs = []
        for group_functions in grouped_funcs.values():
            funcs.extend(group_functions)
    elif is_single:
        funcs = [func_or_list]
    else:
        funcs = func_or_list

    app_kwargs = {"root_path": root_path}

    if fastapi_config:
        conf = fastapi_config.copy()
        if "root_path" in conf:
            conf.pop("root_path") 
        app_kwargs.update(conf)

    app = FastAPI(**app_kwargs)

    @app.on_event("startup")
    async def startup_cleanup():
        """Cleanup old files on startup and run periodic cleanup task."""
        await asyncio.to_thread(cleanup_old_files)

        file_count = get_returned_files_count()
        if file_count > 10000:
            warnings.warn(
                f"Returns directory has {file_count} files. "
                "Consider manually cleaning old files or restarting the server.",
                UserWarning
            )

        async def periodic_cleanup_task():
            """Run cleanup every hour to remove files older than 1 hour."""
            while True:
                await asyncio.sleep(3600)
                await asyncio.to_thread(cleanup_old_files)

        asyncio.create_task(periodic_cleanup_task())

    if template_dir is None:
        template_dir = Path(__file__).parent / "templates"
    else:
        template_dir = Path(template_dir)

    if not template_dir.exists():
        raise FileNotFoundError(f"Template directory '{template_dir}' not found.")

    templates = Jinja2Templates(directory=str(template_dir))
    app.mount("/static", StaticFiles(directory=template_dir / "static"), name="static")

    setup_download_route(app)

    if is_single:
        func = funcs[0]
        params = analyze(func)
        setup_single_function_routes(app, func, params, templates, bool(auth))
    elif is_grouped:
        setup_grouped_function_routes(app, grouped_funcs, templates, bool(auth))
    else:
        setup_multiple_function_routes(app, funcs, templates, bool(auth))

    if auth:
        setup_auth_middleware(app, auth, templates, secret_key)

    uvicorn_params = {
        "host": host,
        "port": port,
        "reload": False,
        "limit_concurrency": 100,
        "limit_max_requests": 1000,
        "timeout_keep_alive": 30,
        "h11_max_incomplete_event_size": 16 * 1024 * 1024
    }

    if "root_path" in kwargs:
        kwargs.pop("root_path")

    uvicorn_params.update(kwargs)

    config_obj = uvicorn.Config(app, **uvicorn_params)
    server = uvicorn.Server(config_obj)
    asyncio.run(server.serve())

func_to_web.server

run(func_or_list, host='0.0.0.0', port=8000, auth=None, secret_key=None, uploads_dir='./uploads', returns_dir='./returned_files', auto_delete_uploads=True, template_dir=None, root_path='', fastapi_config=None, **kwargs)

Generate and run a web UI for one or more Python functions.

Single function mode: Creates a form at root (/) for the function. Multiple functions mode: Creates an index page with links to individual function forms. Grouped functions mode: Creates an index page with grouped sections of functions.

Parameters:

Name Type Description Default
func_or_list Callable[..., Any] | list[Callable[..., Any]] | dict[str, list[Callable[..., Any]]]

A single function, list of functions, or dict of {group_name: [functions]}.

required
host str

Server host address (default: "0.0.0.0").

'0.0.0.0'
port int

Server port (default: 8000).

8000
auth dict[str, str] | None

Optional dictionary of {username: password} for authentication.

None
secret_key str | None

Secret key for session signing (required if auth is used). If None, a random one is generated on startup.

None
uploads_dir str | Path

Directory for uploaded files (default: "./uploads").

'./uploads'
returns_dir str | Path

Directory for returned files (default: "./returned_files").

'./returned_files'
auto_delete_uploads bool

If True, delete uploaded files after processing (default: True).

True
template_dir str | Path | None

Optional custom template directory.

None
root_path str

Prefix for the API path (useful for reverse proxies).

''
fastapi_config dict[str, Any] | None

Optional dictionary with extra arguments for FastAPI app (e.g. {'title': 'My App', 'version': '1.0.0'}).

None
**kwargs

Extra options passed directly to uvicorn.Config. Examples: ssl_keyfile, ssl_certfile, log_level, workers.

{}

Raises:

Type Description
FileNotFoundError

If template directory doesn't exist.

TypeError

If function parameters use unsupported types.

Examples:

Single function: run(my_function)

Multiple functions: run([func1, func2, func3])

Grouped functions: run({ 'Math': [add, subtract, multiply], 'Text': [uppercase, lowercase] })

Notes
  • Returned files are automatically deleted 1 hour after creation (hardcoded).
  • Cleanup runs on startup and then every hour while server is running.
  • Multiple workers are supported (each worker runs its own cleanup task).
Source code in func_to_web\server.py
def run(
    func_or_list: Callable[..., Any] | list[Callable[..., Any]] | dict[str, list[Callable[..., Any]]], 
    host: str = "0.0.0.0", 
    port: int = 8000, 
    auth: dict[str, str] | None = None,
    secret_key: str | None = None,
    uploads_dir: str | Path = "./uploads",
    returns_dir: str | Path = "./returned_files",
    auto_delete_uploads: bool = True,
    template_dir: str | Path | None = None,
    root_path: str = "",
    fastapi_config: dict[str, Any] | None = None,
    **kwargs
) -> None:
    """Generate and run a web UI for one or more Python functions.

    Single function mode: Creates a form at root (/) for the function.
    Multiple functions mode: Creates an index page with links to individual function forms.
    Grouped functions mode: Creates an index page with grouped sections of functions.

    Args:
        func_or_list: A single function, list of functions, or dict of {group_name: [functions]}.
        host: Server host address (default: "0.0.0.0").
        port: Server port (default: 8000).
        auth: Optional dictionary of {username: password} for authentication.
        secret_key: Secret key for session signing (required if auth is used). 
                    If None, a random one is generated on startup.
        uploads_dir: Directory for uploaded files (default: "./uploads").
        returns_dir: Directory for returned files (default: "./returned_files").
        auto_delete_uploads: If True, delete uploaded files after processing (default: True).
        template_dir: Optional custom template directory.
        root_path: Prefix for the API path (useful for reverse proxies).
        fastapi_config: Optional dictionary with extra arguments for FastAPI app 
                        (e.g. {'title': 'My App', 'version': '1.0.0'}).
        **kwargs: Extra options passed directly to `uvicorn.Config`.
                  Examples: `ssl_keyfile`, `ssl_certfile`, `log_level`, `workers`.

    Raises:
        FileNotFoundError: If template directory doesn't exist.
        TypeError: If function parameters use unsupported types.

    Examples:
        Single function:
            run(my_function)

        Multiple functions:
            run([func1, func2, func3])

        Grouped functions:
            run({
                'Math': [add, subtract, multiply],
                'Text': [uppercase, lowercase]
            })

    Notes:
        - Returned files are automatically deleted 1 hour after creation (hardcoded).
        - Cleanup runs on startup and then every hour while server is running.
        - Multiple workers are supported (each worker runs its own cleanup task).
    """

    uploads_path = Path(uploads_dir)
    returns_path = Path(returns_dir)
    uploads_path.mkdir(parents=True, exist_ok=True)
    returns_path.mkdir(parents=True, exist_ok=True)

    config.UPLOADS_DIR = uploads_path
    config.RETURNS_DIR = returns_path
    config.AUTO_DELETE_UPLOADS = auto_delete_uploads

    is_grouped = isinstance(func_or_list, dict)
    is_single = not isinstance(func_or_list, (list, dict))

    if is_grouped:
        grouped_funcs = func_or_list
        funcs = []
        for group_functions in grouped_funcs.values():
            funcs.extend(group_functions)
    elif is_single:
        funcs = [func_or_list]
    else:
        funcs = func_or_list

    app_kwargs = {"root_path": root_path}

    if fastapi_config:
        conf = fastapi_config.copy()
        if "root_path" in conf:
            conf.pop("root_path") 
        app_kwargs.update(conf)

    app = FastAPI(**app_kwargs)

    @app.on_event("startup")
    async def startup_cleanup():
        """Cleanup old files on startup and run periodic cleanup task."""
        await asyncio.to_thread(cleanup_old_files)

        file_count = get_returned_files_count()
        if file_count > 10000:
            warnings.warn(
                f"Returns directory has {file_count} files. "
                "Consider manually cleaning old files or restarting the server.",
                UserWarning
            )

        async def periodic_cleanup_task():
            """Run cleanup every hour to remove files older than 1 hour."""
            while True:
                await asyncio.sleep(3600)
                await asyncio.to_thread(cleanup_old_files)

        asyncio.create_task(periodic_cleanup_task())

    if template_dir is None:
        template_dir = Path(__file__).parent / "templates"
    else:
        template_dir = Path(template_dir)

    if not template_dir.exists():
        raise FileNotFoundError(f"Template directory '{template_dir}' not found.")

    templates = Jinja2Templates(directory=str(template_dir))
    app.mount("/static", StaticFiles(directory=template_dir / "static"), name="static")

    setup_download_route(app)

    if is_single:
        func = funcs[0]
        params = analyze(func)
        setup_single_function_routes(app, func, params, templates, bool(auth))
    elif is_grouped:
        setup_grouped_function_routes(app, grouped_funcs, templates, bool(auth))
    else:
        setup_multiple_function_routes(app, funcs, templates, bool(auth))

    if auth:
        setup_auth_middleware(app, auth, templates, secret_key)

    uvicorn_params = {
        "host": host,
        "port": port,
        "reload": False,
        "limit_concurrency": 100,
        "limit_max_requests": 1000,
        "timeout_keep_alive": 30,
        "h11_max_incomplete_event_size": 16 * 1024 * 1024
    }

    if "root_path" in kwargs:
        kwargs.pop("root_path")

    uvicorn_params.update(kwargs)

    config_obj = uvicorn.Config(app, **uvicorn_params)
    server = uvicorn.Server(config_obj)
    asyncio.run(server.serve())

func_to_web.file_handler

cleanup_old_files()

Remove RETURNED files older than 1 hour (hardcoded).

This runs on startup and every hour while server is running. Only affects files in the returns directory (FileResponse outputs). Uploaded files are cleaned up immediately after processing if AUTO_DELETE_UPLOADS is True.

Source code in func_to_web\file_handler.py
def cleanup_old_files() -> None:
    """Remove RETURNED files older than 1 hour (hardcoded).

    This runs on startup and every hour while server is running.
    Only affects files in the returns directory (FileResponse outputs).
    Uploaded files are cleaned up immediately after processing if AUTO_DELETE_UPLOADS is True.
    """
    try:
        max_age_seconds = config.RETURNS_LIFETIME_SECONDS
        old_file_ids = get_old_returned_files(max_age_seconds)

        for file_id in old_file_ids:
            cleanup_returned_file(file_id, delete_from_disk=True)

    except Exception:
        pass

cleanup_returned_file(file_id, delete_from_disk=True)

Remove RETURNED file from disk.

Parameters:

Name Type Description Default
file_id str

Unique identifier for the returned file.

required
delete_from_disk bool

If True, delete the physical file from disk.

True
Source code in func_to_web\file_handler.py
def cleanup_returned_file(file_id: str, delete_from_disk: bool = True) -> None:
    """Remove RETURNED file from disk.

    Args:
        file_id: Unique identifier for the returned file.
        delete_from_disk: If True, delete the physical file from disk.
    """
    try:
        if delete_from_disk:
            for file_path in config.RETURNS_DIR.iterdir():
                if file_path.is_file():
                    metadata = _decode_filename(file_path.name)
                    if metadata and metadata['file_id'] == file_id:
                        try:
                            os.unlink(file_path)
                        except FileNotFoundError:
                            pass
                        break
    except Exception:
        pass

cleanup_uploaded_file(file_path)

Delete an UPLOADED file from disk.

Parameters:

Name Type Description Default
file_path str

Path to the uploaded file.

required
Source code in func_to_web\file_handler.py
def cleanup_uploaded_file(file_path: str) -> None:
    """Delete an UPLOADED file from disk.

    Args:
        file_path: Path to the uploaded file.
    """
    try:
        if os.path.exists(file_path):
            os.unlink(file_path)
    except Exception:
        pass

create_response_with_files(processed)

Create JSON response with RETURNED file downloads.

Converts file paths to file_ids for the download endpoint. Only processes files returned by user functions (FileResponse).

Parameters:

Name Type Description Default
processed dict[str, Any]

Processed result from process_result().

required

Returns:

Type Description
dict[str, Any]

Response dictionary with file IDs and metadata.

Source code in func_to_web\file_handler.py
def create_response_with_files(processed: dict[str, Any]) -> dict[str, Any]:
    """Create JSON response with RETURNED file downloads.

    Converts file paths to file_ids for the download endpoint.
    Only processes files returned by user functions (FileResponse).

    Args:
        processed: Processed result from process_result().

    Returns:
        Response dictionary with file IDs and metadata.
    """
    response = {"success": True, "result_type": processed['type']}

    if processed['type'] == 'download':
        path = processed['path']
        filename = Path(path).name
        metadata = _decode_filename(filename)

        if metadata:
            response['file_id'] = metadata['file_id']
            response['filename'] = processed['filename']
        else:
            response['file_id'] = 'unknown'
            response['filename'] = processed['filename']

    elif processed['type'] == 'downloads':
        files = []
        for f in processed['files']:
            path = f['path']
            filename_on_disk = Path(path).name
            metadata = _decode_filename(filename_on_disk)

            if metadata:
                files.append({
                    'file_id': metadata['file_id'],
                    'filename': f['filename']
                })
            else:
                files.append({
                    'file_id': 'unknown',
                    'filename': f['filename']
                })
        response['files'] = files

    elif processed['type'] == 'multiple':
        outputs = []
        for output in processed['outputs']:
            output_response = create_response_with_files(output)
            output_response.pop('success', None)
            outputs.append(output_response)
        response['outputs'] = outputs

    elif processed['type'] == 'table':
        response['headers'] = processed['headers']
        response['rows'] = processed['rows']

    else:
        response['result'] = processed['data']

    return response

get_old_returned_files(max_age_seconds)

Get file_ids of RETURNED files older than max_age_seconds.

Parses timestamps from filenames and compares against current time.

Parameters:

Name Type Description Default
max_age_seconds int

Maximum age in seconds.

required

Returns:

Type Description
list[str]

List of file_ids (strings) for returned files.

Source code in func_to_web\file_handler.py
def get_old_returned_files(max_age_seconds: int) -> list[str]:
    """Get file_ids of RETURNED files older than max_age_seconds.

    Parses timestamps from filenames and compares against current time.

    Args:
        max_age_seconds: Maximum age in seconds.

    Returns:
        List of file_ids (strings) for returned files.
    """
    try:
        current_time = int(time.time())
        old_file_ids = []

        for file_path in config.RETURNS_DIR.iterdir():
            if file_path.is_file():
                metadata = _decode_filename(file_path.name)
                if metadata:
                    age = current_time - metadata['timestamp']
                    if age > max_age_seconds:
                        old_file_ids.append(metadata['file_id'])

        return old_file_ids
    except Exception:
        return []

get_returned_file(file_id)

Get RETURNED file info by file_id.

Searches the returns directory for a file matching the file_id.

Parameters:

Name Type Description Default
file_id str

Unique identifier for the returned file.

required

Returns:

Type Description
dict[str, str] | None

Dictionary with 'path' and 'filename' keys, or None if not found.

Source code in func_to_web\file_handler.py
def get_returned_file(file_id: str) -> dict[str, str] | None:
    """Get RETURNED file info by file_id.

    Searches the returns directory for a file matching the file_id.

    Args:
        file_id: Unique identifier for the returned file.

    Returns:
        Dictionary with 'path' and 'filename' keys, or None if not found.
    """
    try:
        for file_path in config.RETURNS_DIR.iterdir():
            if file_path.is_file():
                metadata = _decode_filename(file_path.name)
                if metadata and metadata['file_id'] == file_id:
                    return {
                        'path': str(file_path),
                        'filename': metadata['filename']
                    }
        return None
    except Exception:
        return None

get_returned_files_count()

Get count of RETURNED files in directory.

Returns:

Type Description
int

Number of valid returned files.

Source code in func_to_web\file_handler.py
def get_returned_files_count() -> int:
    """Get count of RETURNED files in directory.

    Returns:
        Number of valid returned files.
    """
    try:
        count = 0
        for file_path in config.RETURNS_DIR.iterdir():
            if file_path.is_file():
                metadata = _decode_filename(file_path.name)
                if metadata:
                    count += 1
        return count
    except Exception:
        return 0

save_returned_file(data, filename)

Save a RETURNED file (from user's FileResponse) to returns directory.

This is SAFE because WE control the filename (it comes from user's code, not from upload). The filename is already validated by Pydantic (max 150 chars) in FileResponse.

Parameters:

Name Type Description Default
data bytes

File content bytes.

required
filename str

Filename from user's FileResponse (already validated).

required

Returns:

Type Description
tuple[str, str]

Tuple of (file_id, file_path).

Source code in func_to_web\file_handler.py
def save_returned_file(data: bytes, filename: str) -> tuple[str, str]:
    """Save a RETURNED file (from user's FileResponse) to returns directory.

    This is SAFE because WE control the filename (it comes from user's code, not from upload).
    The filename is already validated by Pydantic (max 150 chars) in FileResponse.

    Args:
        data: File content bytes.
        filename: Filename from user's FileResponse (already validated).

    Returns:
        Tuple of (file_id, file_path).
    """
    file_id = uuid.uuid4().hex
    timestamp = int(time.time())
    encoded_name = _encode_filename(file_id, timestamp, filename)
    file_path = config.RETURNS_DIR / encoded_name

    with open(file_path, 'wb') as f:
        f.write(data)

    return file_id, str(file_path)

save_uploaded_file(uploaded_file, suffix) async

Save an UPLOADED file (received from user) to uploads directory.

Sanitizes filename, preserves original name (up to 100 chars), and adds unique ID. Format: {sanitized_name}_{unique_id}.{ext} Example: My_Report_2024_a3b4c5d6e7f8g9h0i1j2k3l4m5n6o7p8.pdf

Security features: - Filename sanitization against directory traversal - 100 character limit on user-provided name - UUID for uniqueness filename - Path resolution check to ensure file stays in uploads directory

Parameters:

Name Type Description Default
uploaded_file Any

The uploaded file object from FastAPI.

required
suffix str

File extension to use as fallback.

required

Returns:

Type Description
str

Path to the saved file (string).

Raises:

Type Description
ValueError

If security check fails (file would be outside uploads directory).

Source code in func_to_web\file_handler.py
async def save_uploaded_file(uploaded_file: Any, suffix: str) -> str:
    """Save an UPLOADED file (received from user) to uploads directory.

    Sanitizes filename, preserves original name (up to 100 chars), and adds unique ID.
    Format: {sanitized_name}_{unique_id}.{ext}
    Example: My_Report_2024_a3b4c5d6e7f8g9h0i1j2k3l4m5n6o7p8.pdf

    Security features:
    - Filename sanitization against directory traversal
    - 100 character limit on user-provided name
    - UUID for uniqueness filename
    - Path resolution check to ensure file stays in uploads directory

    Args:
        uploaded_file: The uploaded file object from FastAPI.
        suffix: File extension to use as fallback.

    Returns:
        Path to the saved file (string).

    Raises:
        ValueError: If security check fails (file would be outside uploads directory).
    """
    original_name = uploaded_file.filename if hasattr(uploaded_file, 'filename') else 'file'
    safe_name = _sanitize_filename(original_name)

    name_without_ext, ext = os.path.splitext(safe_name)
    if not ext:
        ext = suffix

    if len(name_without_ext) > MAX_USER_FILENAME_LENGTH:
        name_without_ext = name_without_ext[:MAX_USER_FILENAME_LENGTH]

    unique_id = uuid.uuid4().hex
    final_name = f"{name_without_ext}_{unique_id}{ext}"

    file_path = config.UPLOADS_DIR / final_name
    file_path_resolved = file_path.resolve()
    uploads_dir_resolved = config.UPLOADS_DIR.resolve()

    if not str(file_path_resolved).startswith(str(uploads_dir_resolved)):
        raise ValueError("Security: Invalid file path detected")

    with open(file_path, 'wb', buffering=FILE_BUFFER_SIZE) as f:
        while chunk := await uploaded_file.read(CHUNK_SIZE):
            f.write(chunk)

    return str(file_path)

func_to_web.auth

setup_auth_middleware(app, auth, templates, secret_key=None)

Setup authentication middleware and routes.

Parameters:

Name Type Description Default
app

FastAPI application instance.

required
auth dict[str, str]

Dictionary of {username: password} for authentication.

required
templates Jinja2Templates

Jinja2Templates instance.

required
secret_key str | None

Secret key for session signing.

None
Source code in func_to_web\auth.py
def setup_auth_middleware(app, auth: dict[str, str], templates: Jinja2Templates, secret_key: str | None = None):
    """Setup authentication middleware and routes.

    Args:
        app: FastAPI application instance.
        auth: Dictionary of {username: password} for authentication.
        templates: Jinja2Templates instance.
        secret_key: Secret key for session signing.
    """
    key = secret_key or secrets.token_hex(32)

    # 1. Define Auth Middleware (INNER)
    @app.middleware("http")
    async def auth_middleware(request: Request, call_next):
        path = request.url.path

        # Allow public paths: login page, auth endpoint, and static assets
        if path in ["/login", "/auth"] or path.startswith("/static"):
            return await call_next(request)

        # Check for valid session
        if not request.session.get("user"):
            # If API call (AJAX), return 401
            if "application/json" in request.headers.get("accept", ""):
                return JSONResponse({"error": "Unauthorized"}, status_code=401)
            # If browser navigation, redirect to login
            return RedirectResponse(url="/login")

        return await call_next(request)

    # 2. Add SessionMiddleware (OUTER - runs first)
    app.add_middleware(SessionMiddleware, secret_key=key, https_only=False)

    @app.get("/login")
    async def login_page(request: Request):
        # If already logged in, go home
        if request.session.get("user"):
            return RedirectResponse(url="/")
        return templates.TemplateResponse("login.html", {"request": request})

    @app.post("/auth")
    async def authenticate(request: Request):
        try:
            form = await request.form()
            username = form.get("username")
            password = form.get("password")

            if username in auth:
                # Safe comparison against Timing Attacks
                if secrets.compare_digest(auth[username], password):
                    request.session["user"] = username
                    return RedirectResponse(url="/", status_code=303)

            return templates.TemplateResponse(
                "login.html", 
                {"request": request, "error": "Invalid credentials"}
            )
        except Exception:
            return templates.TemplateResponse(
                "login.html", 
                {"request": request, "error": "Login failed"}
            )

    @app.get("/logout")
    async def logout(request: Request):
        request.session.clear()
        return RedirectResponse(url="/login")

func_to_web.routes

handle_form_submission(request, func, params) async

Handle form submission for any function.

Parameters:

Name Type Description Default
request Request

FastAPI request object.

required
func Callable

Function to call with validated parameters.

required
params dict[str, ParamInfo]

Parameter metadata from analyze().

required

Returns:

Type Description
JSONResponse

JSON response with result or error.

Source code in func_to_web\routes.py
async def handle_form_submission(
    request: Request, 
    func: Callable, 
    params: dict[str, ParamInfo]
) -> JSONResponse:
    """Handle form submission for any function.

    Args:
        request: FastAPI request object.
        func: Function to call with validated parameters.
        params: Parameter metadata from analyze().

    Returns:
        JSON response with result or error.
    """
    uploaded_files = []

    try:
        form_data = await request.form()
        data = {}

        for name, info in params.items():
            if info.is_list:
                raw_values = form_data.getlist(name)

                if not raw_values and name in form_data:
                     raw_values = [form_data[name]]

                processed_list = []
                for val in raw_values:
                    if hasattr(val, 'filename'):
                        suffix = os.path.splitext(val.filename)[1]
                        file_path = await save_uploaded_file(val, suffix)
                        uploaded_files.append(file_path)
                        processed_list.append(file_path)
                    else:
                        processed_list.append(val)

                if len(processed_list) == 1 and isinstance(processed_list[0], str) and processed_list[0].startswith('['):
                    data[name] = processed_list[0]
                else:
                    data[name] = processed_list

            else:
                value = form_data.get(name)
                if hasattr(value, 'filename'):
                    suffix = os.path.splitext(value.filename)[1]
                    file_path = await save_uploaded_file(value, suffix)
                    uploaded_files.append(file_path)
                    data[name] = file_path
                else:
                    data[name] = value

        for key, value in form_data.items():
            if key.endswith('_optional_toggle'):
                data[key] = value

        validated = validate_params(data, params)

        if inspect.iscoroutinefunction(func):
            result = await func(**validated)
        else:
            result = await asyncio.to_thread(func, **validated)

        if config.AUTO_DELETE_UPLOADS:
            for file_path in uploaded_files:
                cleanup_uploaded_file(file_path)

        processed = await asyncio.to_thread(process_result, result)
        response = create_response_with_files(processed)

        return JSONResponse(response)

    except Exception as e:
        if config.AUTO_DELETE_UPLOADS:
            for file_path in uploaded_files:
                cleanup_uploaded_file(file_path)

        return JSONResponse({"success": False, "error": str(e)}, status_code=400)

setup_download_route(app)

Setup file download route.

Parameters:

Name Type Description Default
app

FastAPI application instance.

required
Source code in func_to_web\routes.py
def setup_download_route(app):
    """Setup file download route.

    Args:
        app: FastAPI application instance.
    """
    @app.get("/download/{file_id}")
    async def download_file(file_id: str):
        if not UUID_PATTERN.match(file_id):
            return JSONResponse({"error": "Invalid file ID"}, status_code=400)

        file_info = get_returned_file(file_id)

        if not file_info:
            return JSONResponse({"error": "File not found"}, status_code=404)

        path = file_info['path']
        filename = file_info['filename']

        if not os.path.exists(path):
            cleanup_returned_file(file_id, delete_from_disk=False)
            return JSONResponse({"error": "File expired"}, status_code=404)

        safe_filename = os.path.basename(filename)

        response = FastAPIFileResponse(
            path=path,
            filename=safe_filename,
            media_type='application/octet-stream'
        )

        return response

setup_grouped_function_routes(app, grouped_funcs, templates, has_auth)

Setup routes for grouped functions mode.

Parameters:

Name Type Description Default
app

FastAPI application instance.

required
grouped_funcs dict[str, list[Callable]]

Dictionary of {group_name: [functions]}.

required
templates Jinja2Templates

Jinja2Templates instance.

required
has_auth bool

Whether authentication is enabled.

required
Source code in func_to_web\routes.py
def setup_grouped_function_routes(app, grouped_funcs: dict[str, list[Callable]], templates: Jinja2Templates, has_auth: bool):
    """Setup routes for grouped functions mode.

    Args:
        app: FastAPI application instance.
        grouped_funcs: Dictionary of {group_name: [functions]}.
        templates: Jinja2Templates instance.
        has_auth: Whether authentication is enabled.
    """
    @app.get("/")
    async def index(request: Request):
        groups = []
        for group_name, funcs in grouped_funcs.items():
            tools = [{
                "name": f.__name__.replace('_', ' ').title(),
                "path": f"/{f.__name__}"
            } for f in funcs]

            groups.append({
                "name": group_name,
                "tools": tools
            })

        return templates.TemplateResponse(
            "index.html",
            {"request": request, "groups": groups, "has_auth": has_auth}
        )

    for funcs in grouped_funcs.values():
        for func in funcs:
            _register_function_routes(app, func, templates, has_auth)

setup_multiple_function_routes(app, funcs, templates, has_auth)

Setup routes for multiple functions mode.

Parameters:

Name Type Description Default
app

FastAPI application instance.

required
funcs list[Callable]

List of functions to wrap.

required
templates Jinja2Templates

Jinja2Templates instance.

required
has_auth bool

Whether authentication is enabled.

required
Source code in func_to_web\routes.py
def setup_multiple_function_routes(app, funcs: list[Callable], templates: Jinja2Templates, has_auth: bool):
    """Setup routes for multiple functions mode.

    Args:
        app: FastAPI application instance.
        funcs: List of functions to wrap.
        templates: Jinja2Templates instance.
        has_auth: Whether authentication is enabled.
    """
    @app.get("/")
    async def index(request: Request):
        tools = [{
            "name": f.__name__.replace('_', ' ').title(),
            "path": f"/{f.__name__}"
        } for f in funcs]
        return templates.TemplateResponse(
            "index.html",
            {"request": request, "tools": tools, "has_auth": has_auth}
        )

    for func in funcs:
        _register_function_routes(app, func, templates, has_auth)

setup_single_function_routes(app, func, params, templates, has_auth)

Setup routes for single function mode.

Parameters:

Name Type Description Default
app

FastAPI application instance.

required
func Callable

The function to wrap.

required
params dict

Parameter metadata.

required
templates Jinja2Templates

Jinja2Templates instance.

required
has_auth bool

Whether authentication is enabled.

required
Source code in func_to_web\routes.py
def setup_single_function_routes(app, func: Callable, params: dict, templates: Jinja2Templates, has_auth: bool):
    """Setup routes for single function mode.

    Args:
        app: FastAPI application instance.
        func: The function to wrap.
        params: Parameter metadata.
        templates: Jinja2Templates instance.
        has_auth: Whether authentication is enabled.
    """
    func_name = func.__name__.replace('_', ' ').title()
    description = inspect.getdoc(func)

    @app.get("/")
    async def form(request: Request):
        fields = build_form_fields(params)
        return templates.TemplateResponse(
            "form.html",
            {
                "request": request,
                "title": func_name,
                "description": description,
                "fields": fields,
                "submit_url": "/submit",
                "show_back_button": False,
                "has_auth": has_auth
            }
        )

    @app.post("/submit")
    async def submit(request: Request):
        return await handle_form_submission(request, func, params)

func_to_web.analyze_function

ParamInfo dataclass

Metadata about a function parameter extracted by analyze().

This dataclass stores all the information needed to generate form fields, validate input, and call the function with the correct parameters.

Attributes:

Name Type Description
type type

The base Python type of the parameter. Must be one of: int, float, str, bool, date, or time. Example: int, str, date

default Any

The default value specified in the parameter. - None if the parameter has no default - The actual default value if specified (e.g., 42, "hello", True) - Independent of is_optional (a parameter can be optional with or without a default) Example: For age: int = 25, default is 25 Example: For name: str, default is None

field_info Any

Additional metadata from Pydantic Field or Literal. - For Annotated types: Contains the Field object with constraints (e.g., Field(ge=0, le=100) for numeric bounds, Field(min_length=3) for strings) - For Literal types: Contains the Literal type with valid options - None for basic types without constraints Example: Field(ge=18, le=100) for age constraints Example: Literal['light', 'dark'] for dropdown options

dynamic_func Any

Function for dynamic Literal options. - Only set for Literal[callable] type hints - Called at runtime to generate dropdown options dynamically - Returns a list, tuple, or single value - None for static Literals or non-Literal types Example: A function that returns database options

is_optional bool

Whether the parameter type includes None. - True for Type | None or Union[Type, None] syntax - False for regular required parameters (even if they have a default) - Affects UI: optional fields get a toggle switch to enable/disable - Default: False Example: name: str | None has is_optional=True Example: age: int = 25 has is_optional=False (even with default)

optional_enabled bool

Initial state of optional toggle. - Only relevant when is_optional=True - True: toggle starts enabled (field active) - False: toggle starts disabled (field inactive, sends None) - Determined by: explicit marker > default value > False - Default: False Example: name: str | OptionalEnabled starts enabled Example: name: str | OptionalDisabled starts disabled Example: name: str | None = "John" starts enabled (has default) Example: name: str | None starts disabled (no default)

is_list bool

Whether the parameter is a list type. - True for list[Type] syntax - False for regular parameters - When True, 'type' contains the item type, not list - Default: False

list_field_info Any

Metadata for the list itself (min_items, max_items). - Only relevant when is_list=True - Contains Field constraints for the list container - None if no list-level constraints Example: Field(min_items=2, max_items=5)

enum_type None

The original Enum type if parameter was an Enum. - None for non-Enum parameters - Stored to convert string back to Enum in validation - Example: For color: Color, stores the Color Enum class

Source code in func_to_web\analyze_function.py
@dataclass
class ParamInfo:
    """Metadata about a function parameter extracted by analyze().

    This dataclass stores all the information needed to generate form fields,
    validate input, and call the function with the correct parameters.

    Attributes:
        type: The base Python type of the parameter. Must be one of:
            int, float, str, bool, date, or time.
            Example: int, str, date
        default: The default value specified in the parameter.
            - None if the parameter has no default
            - The actual default value if specified (e.g., 42, "hello", True)
            - Independent of is_optional (a parameter can be optional with or without a default)
            Example: For `age: int = 25`, default is 25
            Example: For `name: str`, default is None
        field_info: Additional metadata from Pydantic Field or Literal.
            - For Annotated types: Contains the Field object with constraints
              (e.g., Field(ge=0, le=100) for numeric bounds, Field(min_length=3) for strings)
            - For Literal types: Contains the Literal type with valid options
            - None for basic types without constraints
            Example: Field(ge=18, le=100) for age constraints
            Example: Literal['light', 'dark'] for dropdown options
        dynamic_func: Function for dynamic Literal options.
            - Only set for Literal[callable] type hints
            - Called at runtime to generate dropdown options dynamically
            - Returns a list, tuple, or single value
            - None for static Literals or non-Literal types
            Example: A function that returns database options
        is_optional: Whether the parameter type includes None.
            - True for Type | None or Union[Type, None] syntax
            - False for regular required parameters (even if they have a default)
            - Affects UI: optional fields get a toggle switch to enable/disable
            - Default: False
            Example: `name: str | None` has is_optional=True
            Example: `age: int = 25` has is_optional=False (even with default)
        optional_enabled: Initial state of optional toggle.
            - Only relevant when is_optional=True
            - True: toggle starts enabled (field active)
            - False: toggle starts disabled (field inactive, sends None)
            - Determined by: explicit marker > default value > False
            - Default: False
            Example: `name: str | OptionalEnabled` starts enabled
            Example: `name: str | OptionalDisabled` starts disabled
            Example: `name: str | None = "John"` starts enabled (has default)
            Example: `name: str | None` starts disabled (no default)
        is_list: Whether the parameter is a list type.
            - True for list[Type] syntax
            - False for regular parameters
            - When True, 'type' contains the item type, not list
            - Default: False
        list_field_info: Metadata for the list itself (min_items, max_items).
            - Only relevant when is_list=True
            - Contains Field constraints for the list container
            - None if no list-level constraints
            Example: Field(min_items=2, max_items=5)
        enum_type: The original Enum type if parameter was an Enum.
            - None for non-Enum parameters
            - Stored to convert string back to Enum in validation
            - Example: For `color: Color`, stores the Color Enum class
    """
    type: type
    default: Any = None
    field_info: Any = None
    dynamic_func: Any = None
    is_optional: bool = False
    optional_enabled: bool = False
    is_list: bool = False
    list_field_info: Any = None
    enum_type: None = None

analyze(func)

Analyze a function's signature and extract parameter metadata.

Parameters:

Name Type Description Default
func Callable[..., Any]

The function to analyze.

required

Returns:

Type Description
dict[str, ParamInfo]

Mapping of parameter names to ParamInfo objects.

Raises:

Type Description
TypeError

If parameter type is not supported.

TypeError

If list has no type parameter.

TypeError

If list item type is not supported.

TypeError

If list of Literal is used (conceptually confusing).

TypeError

If list default is not a list.

TypeError

If list default items have wrong type.

ValueError

If default value doesn't match Literal options.

ValueError

If Literal options are invalid.

ValueError

If Union has multiple non-None types.

ValueError

If default value type doesn't match parameter type.

Source code in func_to_web\analyze_function.py
def analyze(func: Callable[..., Any]) -> dict[str, ParamInfo]:
    """Analyze a function's signature and extract parameter metadata.

    Args:
        func: The function to analyze.

    Returns:
        Mapping of parameter names to ParamInfo objects.

    Raises:
        TypeError: If parameter type is not supported.
        TypeError: If list has no type parameter.
        TypeError: If list item type is not supported.
        TypeError: If list of Literal is used (conceptually confusing).
        TypeError: If list default is not a list.
        TypeError: If list default items have wrong type.
        ValueError: If default value doesn't match Literal options.
        ValueError: If Literal options are invalid.
        ValueError: If Union has multiple non-None types.
        ValueError: If default value type doesn't match parameter type.
    """

    result = {}

    for name, p in inspect.signature(func).parameters.items():
        default = None if p.default == inspect.Parameter.empty else p.default
        t = p.annotation
        f = None
        list_f = None  # Field info for the list itself
        dynamic_func = None
        is_optional = False
        optional_default_enabled = None  # None = auto, True = enabled, False = disabled
        is_list = False
        enum_type = None

        # 1. Extract base type from Annotated (OUTER level)
        # This could be constraints for the list itself
        if get_origin(t) is Annotated:
            args = get_args(t)
            t = args[0]
            if len(args) > 1:
                # Store this temporarily - we'll decide if it's for list or item later
                list_f = args[1]

        # 2. Check for Union types (including | None syntax) BEFORE list detection
        if get_origin(t) is types.UnionType or str(get_origin(t)) == 'typing.Union':
            union_args = get_args(t)

            # First pass: detect markers and check for None
            has_none = type(None) in union_args

            for arg in union_args:
                if get_origin(arg) is Annotated:
                    annotated_args = get_args(arg)
                    # Check if this is Annotated[None, Marker]
                    if annotated_args[0] is type(None) and len(annotated_args) > 1:
                        for marker in annotated_args[1:]:
                            if isinstance(marker, _OptionalEnabledMarker):
                                optional_default_enabled = True
                                is_optional = True
                            elif isinstance(marker, _OptionalDisabledMarker):
                                optional_default_enabled = False
                                is_optional = True

            # Second pass: extract the actual type (not None, not markers)
            if has_none or is_optional:
                is_optional = True
                non_none_types = []

                for arg in union_args:
                    # Skip plain None
                    if arg is type(None):
                        continue

                    # Skip Annotated[None, Marker] (the markers)
                    if get_origin(arg) is Annotated:
                        annotated_args = get_args(arg)
                        if annotated_args[0] is type(None):
                            continue

                    # This is the actual type
                    non_none_types.append(arg)

                if len(non_none_types) == 0:
                    raise TypeError(f"'{name}': Cannot have only None type")
                elif len(non_none_types) > 1:
                    raise TypeError(f"'{name}': Union with multiple non-None types not supported")

                # Extract the actual type
                t = non_none_types[0]

                # Check again if this is Annotated (for Field constraints)
                if get_origin(t) is Annotated:
                    args = get_args(t)
                    t = args[0]
                    if len(args) > 1 and list_f is None:
                        list_f = args[1]

        # 3. Detect list type
        if get_origin(t) is list:
            is_list = True
            list_args = get_args(t)

            if not list_args:
                raise TypeError(f"'{name}': list must have type parameter (e.g., list[int])")

            # Extract item type
            t = list_args[0]

            # Check if item type is Literal (before extracting Annotated)
            if get_origin(t) is Literal:
                raise TypeError(f"'{name}': list of Literal not supported")

            # 4. Extract Annotated from ITEM type
            if get_origin(t) is Annotated:
                args = get_args(t)
                t = args[0]

                # Check again for Literal after extracting Annotated
                if get_origin(t) is Literal:
                    raise TypeError(f"'{name}': list of Literal not supported")

                if len(args) > 1:
                    f = args[1]  # Field constraints for EACH ITEM
        elif t is list:
            # Handle bare 'list' without type parameter
            raise TypeError(f"'{name}': list must have type parameter (e.g., list[int])")

        # If not a list, then list_f is actually the field_info for the item
        if not is_list and list_f is not None:
            f = list_f
            list_f = None

        # 4.5 Detect and process Dropdown
        dropdown_instance = None

        # Check if Dropdown is in metadata
        if f and isinstance(f, Dropdown):
            dropdown_instance = f
            f = None
        elif list_f and isinstance(list_f, Dropdown):
            dropdown_instance = list_f
            list_f = None

        # Process Dropdown if found
        if dropdown_instance:
            # Execute function to get options
            opts = dropdown_instance.data_function()

            # Validate it's a list
            if not isinstance(opts, list):
                raise TypeError(f"'{name}': Dropdown function must return a list, got {type(opts).__name__}")

            if not opts:
                raise ValueError(f"'{name}': Dropdown function returned empty list")

            # Validate all options are same type
            types_set = {type(o) for o in opts}
            if len(types_set) > 1:
                raise TypeError(f"'{name}': Dropdown returned mixed types")

            # Validate returned type matches declared type
            returned_type = types_set.pop()
            if returned_type != t:
                raise TypeError(
                    f"'{name}': Dropdown type mismatch. "
                    f"Declared type is {t.__name__}, but function returned {returned_type.__name__}"
                )

            # Validate default against options
            if not is_list and default is not None and default not in opts:
                raise ValueError(f"'{name}': default '{default}' not in Dropdown options {opts}")

            # Convert to Literal for rest of pipeline
            f = Literal[tuple(opts)]
            dynamic_func = dropdown_instance.data_function

        # 5. Handle Literal types (dropdowns)
        if get_origin(t) is Literal:
            opts = get_args(t)

            # Check if opts contains a single callable (dynamic Literal)
            if len(opts) == 1 and callable(opts[0]):
                dynamic_func = opts[0]
                result_value = dynamic_func()

                # Convert result to tuple properly
                if isinstance(result_value, (list, tuple)):
                    opts = tuple(result_value)
                else:
                    opts = (result_value,)

            # Validate options
            if opts:
                types_set = {type(o) for o in opts}
                if len(types_set) > 1:
                    raise TypeError(f"'{name}': mixed types in Literal")

                # For lists, we can't validate default against Literal here (it's a list)
                if not is_list and default is not None and default not in opts:
                    raise ValueError(f"'{name}': default '{default}' not in options {opts}")

                f = Literal[opts] if len(opts) > 0 else t
                t = types_set.pop() if types_set else type(None)
            else:
                t = type(None)

        # 5b. Handle Enum types
        elif isinstance(t, type) and issubclass(t, Enum):
            opts = tuple(e.value for e in t)

            if not opts:
                raise ValueError(f"'{name}': Enum must have at least one value")

            types_set = {type(v) for v in opts}
            if len(types_set) > 1:
                raise TypeError(f"'{name}': Enum values must be same type")

            if default is not None:
                if not isinstance(default, t):
                    raise TypeError(f"'{name}': default must be {t.__name__} instance")
                default = default.value

            enum_type = t

            f = Literal[opts]
            t = types_set.pop()

        # 6. Validate base type
        if t not in VALID:
            raise TypeError(f"'{name}': {t} not supported")

        # 7. Validate default value
        if default is not None:
            if is_list:
                # Must be a list
                if not isinstance(default, list):
                    raise TypeError(f"'{name}': default must be a list")

                # Validate list-level constraints BEFORE converting empty list to None
                if list_f and hasattr(list_f, 'metadata'):
                    TypeAdapter(Annotated[list[t], list_f]).validate_python(default)

                # Validate each item
                for item in default:
                    # Check type
                    if not isinstance(item, t):
                        raise TypeError(f"'{name}': list item type mismatch in default")

                    # Validate against Field constraints (for items)
                    if f and hasattr(f, 'metadata'):
                        TypeAdapter(Annotated[t, f]).validate_python(item)

                # Convert empty list to None AFTER validation
                if len(default) == 0:
                    default = None
            else:
                # Non-list validation (existing logic)
                if not is_optional and get_origin(f) is not Literal:
                    if not isinstance(default, t):
                        raise TypeError(f"'{name}': default value type mismatch")

                # Validate default value against field constraints
                if f and hasattr(f, 'metadata'):
                    TypeAdapter(Annotated[t, f]).validate_python(default)

        # 8. Determine optional_enabled state
        # Priority: explicit marker > default value presence > False
        if optional_default_enabled is not None:
            # Explicit marker takes priority
            final_optional_enabled = optional_default_enabled
        elif default is not None:
            # Has default value, start enabled
            final_optional_enabled = True
        else:
            # No default, start disabled
            final_optional_enabled = False

        result[name] = ParamInfo(t, default, f, dynamic_func, is_optional, final_optional_enabled, is_list, list_f, enum_type)

    return result

func_to_web.validate_params

validate_list_param(value, info, param_name)

Validate and convert a JSON string to a typed list.

Parameters:

Name Type Description Default
value str | list | None

JSON string like "[1, 2, 3]" or "[]".

required
info ParamInfo

ParamInfo with type and constraints for list items.

required
param_name str

Name of the parameter (for error messages).

required

Returns:

Type Description
list

Validated list with proper types.

Raises:

Type Description
TypeError

If value is not a valid list.

ValueError

If items don't pass validation or list size constraints are violated.

JSONDecodeError

If JSON is invalid.

Source code in func_to_web\validate_params.py
def validate_list_param(value: str | list | None, info: ParamInfo, param_name: str) -> list:
    """Validate and convert a JSON string to a typed list.

    Args:
        value: JSON string like "[1, 2, 3]" or "[]".
        info: ParamInfo with type and constraints for list items.
        param_name: Name of the parameter (for error messages).

    Returns:
        Validated list with proper types.

    Raises:
        TypeError: If value is not a valid list.
        ValueError: If items don't pass validation or list size constraints are violated.
        json.JSONDecodeError: If JSON is invalid.
    """
    # Parse JSON
    if isinstance(value, list):
        list_value = value
    elif not value or value == "":
        list_value = []
    else:
        try:
            list_value = json.loads(value)
        except json.JSONDecodeError as e:
            raise ValueError(f"'{param_name}': Invalid list format: {e}")

    if not isinstance(list_value, list):
        raise TypeError(f"'{param_name}': Expected list, got {type(list_value).__name__}")

    # Validate list-level constraints (min_length, max_length)
    if info.list_field_info and hasattr(info.list_field_info, 'metadata'):
        min_length = None
        max_length = None

        for constraint in info.list_field_info.metadata:
            constraint_name = type(constraint).__name__

            if constraint_name == 'MinLen':
                min_length = constraint.min_length
            elif constraint_name == 'MaxLen':
                max_length = constraint.max_length
            elif hasattr(constraint, 'min_length'):
                min_length = constraint.min_length
            elif hasattr(constraint, 'max_length'):
                max_length = constraint.max_length

        # Validate min_length
        if min_length is not None and len(list_value) < min_length:
            raise ValueError(
                f"'{param_name}': List must have at least {min_length} item{'s' if min_length != 1 else ''}, "
                f"got {len(list_value)}"
            )

        # Validate max_length
        if max_length is not None and len(list_value) > max_length:
            raise ValueError(
                f"'{param_name}': List must have at most {max_length} item{'s' if max_length != 1 else ''}, "
                f"got {len(list_value)}"
            )

    # Validate each item
    validated_list = []
    for i, item in enumerate(list_value):
        try:
            validated_item = validate_single_item(item, info)
            validated_list.append(validated_item)
        except (ValueError, TypeError) as e:
            raise ValueError(f"'{param_name}': List item at index {i}: {e}")

    return validated_list

validate_params(form_data, params_info)

Validate and convert form data to function parameters.

This function takes raw form data (where everything is a string) and converts it to the proper Python types based on the parameter metadata from analyze(). It handles type conversion, optional field toggles, and validates against constraints defined in Pydantic Field or Literal types.

Process
  1. Check if optional fields are enabled via toggle
  2. Convert strings to proper types (int, float, date, time, bool)
  3. For lists: parse JSON and validate each item
  4. Validate Literal values against allowed options
  5. Validate against Pydantic Field constraints (ge, le, min_length, etc.)
  6. Handle special cases (hex color expansion, empty values)

Parameters:

Name Type Description Default
form_data dict

Raw form data from HTTP request. - Keys are parameter names (str) - Values are form values (str, or None for checkboxes) - For lists: JSON string like "[1, 2, 3]" - Optional toggles have keys like "{param}_optional_toggle"

required
params_info dict[str, ParamInfo]

Parameter metadata from analyze(). - Keys are parameter names (str) - Values are ParamInfo objects with type and validation info

required

Returns:

Type Description
dict

Validated parameters ready for function call.

dict

Keys are parameter names (str), values are properly typed Python objects.

Raises:

Type Description
ValueError

If a value doesn't match Literal options or Field constraints.

TypeError

If type conversion fails.

JSONDecodeError

If list JSON is invalid.

Source code in func_to_web\validate_params.py
def validate_params(form_data: dict, params_info: dict[str, ParamInfo]) -> dict:
    """Validate and convert form data to function parameters.

    This function takes raw form data (where everything is a string) and converts
    it to the proper Python types based on the parameter metadata from analyze().
    It handles type conversion, optional field toggles, and validates against
    constraints defined in Pydantic Field or Literal types.

    Process:
        1. Check if optional fields are enabled via toggle
        2. Convert strings to proper types (int, float, date, time, bool)
        3. For lists: parse JSON and validate each item
        4. Validate Literal values against allowed options
        5. Validate against Pydantic Field constraints (ge, le, min_length, etc.)
        6. Handle special cases (hex color expansion, empty values)

    Args:
        form_data: Raw form data from HTTP request.
            - Keys are parameter names (str)
            - Values are form values (str, or None for checkboxes)
            - For lists: JSON string like "[1, 2, 3]"
            - Optional toggles have keys like "{param}_optional_toggle"
        params_info: Parameter metadata from analyze().
            - Keys are parameter names (str)
            - Values are ParamInfo objects with type and validation info

    Returns:
        Validated parameters ready for function call.
        Keys are parameter names (str), values are properly typed Python objects.

    Raises:
        ValueError: If a value doesn't match Literal options or Field constraints.
        TypeError: If type conversion fails.
        json.JSONDecodeError: If list JSON is invalid.
    """
    validated = {}

    for name, info in params_info.items():
        value = form_data.get(name)

        # Check if optional field is disabled
        optional_toggle_name = f"{name}_optional_toggle"
        if info.is_optional and optional_toggle_name not in form_data:
            # Optional field is disabled, send None
            validated[name] = None
            continue

        # Handle list fields
        if info.is_list:
            validated[name] = validate_list_param(value, info, name)
            continue

        # Checkbox handling
        if info.type is bool:
            validated[name] = value is not None
            continue

        # Date conversion
        if info.type is date:
            if value:
                validated[name] = date.fromisoformat(value)
            else:
                validated[name] = None
            continue

        # Time conversion
        if info.type is time:
            if value:
                validated[name] = time.fromisoformat(value)
            else:
                validated[name] = None
            continue

        # Literal validation
        if get_origin(info.field_info) is Literal:
            # Convert to correct type
            if info.type is int:
                value = int(value)
            elif info.type is float:
                value = float(value)

            # Only validate against options if Literal is NOT dynamic
            if info.dynamic_func is None:
                opts = get_args(info.field_info)
                if value not in opts:
                    raise ValueError(f"'{name}': value '{value}' not in {opts}")

            # Convert string → Enum if needed
            if info.enum_type is not None:
                # Find the Enum member with this value
                for member in info.enum_type:
                    if member.value == value:
                        value = member
                        break
                else:
                    # This shouldn't happen if validation passed
                    raise ValueError(f"'{name}': invalid value for {info.enum_type.__name__}")

            validated[name] = value
            continue

        # Expand shorthand hex colors (#RGB -> #RRGGBB)
        if value and isinstance(value, str) and value.startswith('#') and len(value) == 4:
            value = '#' + ''.join(c*2 for c in value[1:])

        # Pydantic validation with constraints
        if info.field_info and hasattr(info.field_info, 'metadata'):
            adapter = TypeAdapter(Annotated[info.type, info.field_info])
            validated[name] = adapter.validate_python(value)
        else:
            validated[name] = info.type(value) if value else None

    return validated

validate_single_item(item, info)

Validate a single list item.

Reuses the same validation logic as non-list parameters.

Parameters:

Name Type Description Default
item Any

The item value from the JSON array.

required
info ParamInfo

ParamInfo with type and constraints.

required

Returns:

Type Description
Any

Validated and converted item.

Source code in func_to_web\validate_params.py
def validate_single_item(item: Any, info: ParamInfo) -> Any:
    """Validate a single list item.

    Reuses the same validation logic as non-list parameters.

    Args:
        item: The item value from the JSON array.
        info: ParamInfo with type and constraints.

    Returns:
        Validated and converted item.
    """
    # Handle None/null values
    if item is None:
        return None

    # Bool (already bool from JSON)
    if info.type is bool:
        return bool(item)

    # Date (comes as string from JSON)
    if info.type is date:
        if isinstance(item, str):
            return date.fromisoformat(item)
        return item

    # Time (comes as string from JSON)
    if info.type is time:
        if isinstance(item, str):
            return time.fromisoformat(item)
        return item

    # Literal in lists is not supported (prohibited by analyze())
    if get_origin(info.field_info) is Literal:
        raise TypeError("list[Literal[...]] is not supported")

    # Expand shorthand hex colors (#RGB -> #RRGGBB)
    if item and isinstance(item, str) and item.startswith('#') and len(item) == 4:
        item = '#' + ''.join(c*2 for c in item[1:])

    # Number types: ensure conversion from string if needed
    if info.type in (int, float):
        if isinstance(item, str):
            item = info.type(item)
        elif isinstance(item, (int, float)):
            # JSON already parsed it as number
            item = info.type(item)

    # Pydantic validation with constraints
    if info.field_info and hasattr(info.field_info, 'metadata'):
        adapter = TypeAdapter(Annotated[info.type, info.field_info])
        return adapter.validate_python(item)
    else:
        # Basic type conversion for types without constraints
        if info.type in (int, float):
            # Already converted above
            return item
        elif info.type is str:
            return str(item) if item is not None else None
        else:
            # For other types (shouldn't reach here normally)
            return info.type(item) if item is not None else None

func_to_web.build_form_fields

build_form_fields(params_info)

Build form field specifications from parameter metadata.

Re-executes dynamic functions to get fresh options.

This function takes the analyzed parameter information from analyze() and converts it into a list of field specifications that can be used by the template engine to generate HTML form inputs.

Process
  1. Iterate through each parameter's ParamInfo
  2. Determine the appropriate HTML input type (text, number, select, etc.)
  3. Extract constraints and convert them to HTML attributes
  4. Handle special cases (optional fields, dynamic literals, files, etc.)
  5. Serialize defaults to JSON-safe format
  6. Return list of field dictionaries ready for template rendering

Parameters:

Name Type Description Default
params_info dict

Mapping of parameter names to ParamInfo objects. Keys are parameter names (str), values are ParamInfo objects with type, default, field_info, etc.

required

Returns:

Type Description
list[dict[str, Any]]

List of field dictionaries for template rendering. Each dictionary contains:

list[dict[str, Any]]
  • name (str): Parameter name
list[dict[str, Any]]
  • type (str): HTML input type ('text', 'number', 'select', etc.)
list[dict[str, Any]]
  • default (Any): Default value for the field (JSON-serialized)
list[dict[str, Any]]
  • required (bool): Whether field is required (lists are ALWAYS required)
list[dict[str, Any]]
  • is_optional (bool): Whether field has optional toggle
list[dict[str, Any]]
  • optional_enabled (bool): Whether optional field starts enabled
list[dict[str, Any]]
  • is_list (bool): Whether this is a list field
list[dict[str, Any]]
  • list_min_length (int): For list fields, minimum number of items
list[dict[str, Any]]
  • list_max_length (int): For list fields, maximum number of items
list[dict[str, Any]]
  • options (tuple): For select fields, the dropdown options
list[dict[str, Any]]
  • min/max (int/float): For number fields, numeric constraints
list[dict[str, Any]]
  • minlength/maxlength (int): For text fields, length constraints
list[dict[str, Any]]
  • pattern (str): Regex pattern for validation
list[dict[str, Any]]
  • accept (str): For file fields, accepted file extensions
list[dict[str, Any]]
  • step (str): For number fields, '1' for int, 'any' for float
Field Type Detection
  • Literal types → 'select' (dropdown)
  • bool → 'checkbox'
  • date → 'date' (date picker)
  • time → 'time' (time picker)
  • int/float → 'number' (with constraints)
  • str with file pattern → 'file' (file upload)
  • str with color pattern → 'color' (color picker)
  • str with email pattern → 'email' (email input)
  • str (default) → 'text' (text input)
Source code in func_to_web\build_form_fields.py
def build_form_fields(params_info: dict) -> list[dict[str, Any]]:
    """Build form field specifications from parameter metadata.

    Re-executes dynamic functions to get fresh options.

    This function takes the analyzed parameter information from analyze() and
    converts it into a list of field specifications that can be used by the
    template engine to generate HTML form inputs.

    Process:
        1. Iterate through each parameter's ParamInfo
        2. Determine the appropriate HTML input type (text, number, select, etc.)
        3. Extract constraints and convert them to HTML attributes
        4. Handle special cases (optional fields, dynamic literals, files, etc.)
        5. Serialize defaults to JSON-safe format
        6. Return list of field dictionaries ready for template rendering

    Args:
        params_info: Mapping of parameter names to ParamInfo objects.
            Keys are parameter names (str), values are ParamInfo objects with 
            type, default, field_info, etc.

    Returns:
        List of field dictionaries for template rendering. Each dictionary contains:

        - name (str): Parameter name
        - type (str): HTML input type ('text', 'number', 'select', etc.)
        - default (Any): Default value for the field (JSON-serialized)
        - required (bool): Whether field is required (lists are ALWAYS required)
        - is_optional (bool): Whether field has optional toggle
        - optional_enabled (bool): Whether optional field starts enabled
        - is_list (bool): Whether this is a list field
        - list_min_length (int): For list fields, minimum number of items
        - list_max_length (int): For list fields, maximum number of items
        - options (tuple): For select fields, the dropdown options
        - min/max (int/float): For number fields, numeric constraints
        - minlength/maxlength (int): For text fields, length constraints
        - pattern (str): Regex pattern for validation
        - accept (str): For file fields, accepted file extensions
        - step (str): For number fields, '1' for int, 'any' for float

    Field Type Detection:
        - Literal types → 'select' (dropdown)
        - bool → 'checkbox'
        - date → 'date' (date picker)
        - time → 'time' (time picker)
        - int/float → 'number' (with constraints)
        - str with file pattern → 'file' (file upload)
        - str with color pattern → 'color' (color picker)
        - str with email pattern → 'email' (email input)
        - str (default) → 'text' (text input)
    """
    fields = []

    for name, info in params_info.items():
        # Serialize default value to JSON-safe format
        serialized_default = serialize_for_json(info.default)

        field = {
            'name': name, 
            'default': serialized_default,
            'required': True if info.is_list else not info.is_optional,
            'is_optional': info.is_optional,
            'optional_enabled': info.optional_enabled,
            'is_list': info.is_list
        }

        if info.is_list and info.list_field_info and hasattr(info.list_field_info, 'metadata'):
            for c in info.list_field_info.metadata:
                cn = type(c).__name__
                if cn == 'MinLen':
                    field['list_min_length'] = c.min_length
                if cn == 'MaxLen':
                    field['list_max_length'] = c.max_length

        # Dropdown select
        if get_origin(info.field_info) is Literal:
            field['type'] = 'select'

            # Re-execute dynamic function if present
            if info.dynamic_func is not None:
                result_value = info.dynamic_func()

                # Convert result to tuple properly
                if isinstance(result_value, (list, tuple)):
                    fresh_options = tuple(result_value)
                else:
                    fresh_options = (result_value,)

                field['options'] = fresh_options
                info.field_info = Literal[fresh_options]
            else:
                field['options'] = get_args(info.field_info)

        # Checkbox
        elif info.type is bool:
            field['type'] = 'checkbox'
            field['required'] = False

        # Date picker
        elif info.type is date:
            field['type'] = 'date'
            # Already serialized above, no need to do it again

        # Time picker
        elif info.type is time:
            field['type'] = 'time'
            # Already serialized above, no need to do it again

        # Number input
        elif info.type in (int, float):
            field['type'] = 'number'
            field['step'] = '1' if info.type is int else 'any'

            # Extract numeric constraints from Pydantic Field
            if info.field_info and hasattr(info.field_info, 'metadata'):
                for c in info.field_info.metadata:
                    cn = type(c).__name__
                    if cn == 'Ge': field['min'] = c.ge
                    elif cn == 'Le': field['max'] = c.le
                    elif cn == 'Gt': field['min'] = c.gt + (1 if info.type is int else 0.01)
                    elif cn == 'Lt': field['max'] = c.lt - (1 if info.type is int else 0.01)

        # Text/email/color/file input
        else:
            field['type'] = 'text'

            if info.field_info and hasattr(info.field_info, 'metadata'):
                for c in info.field_info.metadata:
                    cn = type(c).__name__

                    # Check for pattern constraints
                    if hasattr(c, 'pattern') and c.pattern:
                        pattern = c.pattern

                        # Generic File input (accepts everything)
                        if pattern == ANY_FILE_PATTERN:
                            field['type'] = 'file'
                        # File input detection
                        elif pattern.startswith(r'^.+\.(') and pattern.endswith(r')$'):
                            field['type'] = 'file'
                            exts = pattern[6:-2].split('|')
                            field['accept'] = '.' + ',.'.join(exts)
                        # Special input types (color, email)
                        elif pattern in PATTERN_TO_HTML_TYPE:
                            field['type'] = PATTERN_TO_HTML_TYPE[pattern]

                        field['pattern'] = pattern

                    # String length constraints
                    if cn == 'MinLen': 
                        field['minlength'] = c.min_length
                    if cn == 'MaxLen':
                        field['maxlength'] = c.max_length

        fields.append(field)

    return fields

serialize_for_json(value)

Serialize a value to be JSON-safe for template rendering.

Converts date/time objects to ISO format strings.

Parameters:

Name Type Description Default
value Any

The value to serialize (can be any type).

required

Returns:

Type Description
Any

JSON-safe serialized value (str for dates/times, or original type).

Source code in func_to_web\build_form_fields.py
def serialize_for_json(value: Any) -> Any:
    """Serialize a value to be JSON-safe for template rendering.

    Converts date/time objects to ISO format strings.

    Args:
        value: The value to serialize (can be any type).

    Returns:
        JSON-safe serialized value (str for dates/times, or original type).
    """
    if value is None:
        return None

    if isinstance(value, date):
        return value.isoformat()

    if isinstance(value, time):
        return value.isoformat()

    if isinstance(value, list):
        return [serialize_for_json(item) for item in value]

    if isinstance(value, dict):
        return {k: serialize_for_json(v) for k, v in value.items()}

    return value

func_to_web.process_result

process_result(result)

Convert function result to appropriate display format.

Source code in func_to_web\process_result.py
def process_result(result):
    """
    Convert function result to appropriate display format.
    """
    # ===== PANDAS DATAFRAME =====
    try:
        import pandas as pd
        if isinstance(result, pd.DataFrame):
            headers = result.columns.tolist()
            rows = [[str(cell) for cell in row] for row in result.values.tolist()]
            return {
                'type': 'table',
                'headers': headers,
                'rows': rows
            }
    except ImportError:
        pass

    # ===== NUMPY 2D ARRAY =====
    try:
        import numpy as np
        if isinstance(result, np.ndarray) and result.ndim == 2:
            headers = [f"Column {i+1}" for i in range(result.shape[1])]
            rows = [[str(cell) for cell in row] for row in result.tolist()]
            return {
                'type': 'table',
                'headers': headers,
                'rows': rows
            }
    except ImportError:
        pass

    # ===== POLARS DATAFRAME =====
    try:
        import polars as pl
        if isinstance(result, pl.DataFrame):
            headers = result.columns
            rows = [[str(cell) for cell in row] for row in result.rows()]
            return {
                'type': 'table',
                'headers': headers,
                'rows': rows
            }
    except ImportError:
        pass

    # ===== TABLE DETECTION =====
    table_result = detect_and_convert_table(result)
    if table_result is not None:
        return table_result

    # ===== TUPLE/LIST HANDLING =====
    if isinstance(result, (tuple, list)):
        # Empty tuple/list
        if len(result) == 0:
            return {'type': 'text', 'data': str(result)}

        # Check for nested tuples/lists that are NOT valid tables
        for nested_item in result:
            if isinstance(nested_item, (tuple, list)):
                # If it's a valid table, allow it
                if not (is_homogeneous_list_of_dicts(nested_item) or is_homogeneous_list_of_tuples(nested_item)):
                    raise ValueError("Nested tuples/lists are not supported. Please flatten your return structure.")

        # Special case: list of FileResponse
        if all(isinstance(f, UserFileResponse) for f in result):
            files = []
            for f in result:
                file_id, file_path = _process_file_response(f)
                files.append({
                    'path': file_path,
                    'filename': f.filename
                })
            return {
                'type': 'downloads',
                'files': files
            }

        # General case: process each item recursively
        outputs = []
        for item in result:
            outputs.append(process_result(item))

        return {
            'type': 'multiple',
            'outputs': outputs
        }

    # ===== PIL IMAGE =====
    try:
        from PIL import Image
        if isinstance(result, Image.Image):
            buffer = io.BytesIO()
            result.save(buffer, format='PNG')
            buffer.seek(0)
            img_base64 = base64.b64encode(buffer.read()).decode()
            return {
                'type': 'image',
                'data': f'data:image/png;base64,{img_base64}'
            }
    except ImportError:
        pass

    # ===== MATPLOTLIB FIGURE =====
    try:
        import matplotlib.pyplot as plt
        from matplotlib.figure import Figure
        if isinstance(result, Figure):
            buffer = io.BytesIO()
            result.savefig(buffer, format='png', bbox_inches='tight')
            buffer.seek(0)
            img_base64 = base64.b64encode(buffer.read()).decode()
            plt.close(result)
            return {
                'type': 'image',
                'data': f'data:image/png;base64,{img_base64}'
            }
    except ImportError:
        pass

    # ===== SINGLE FILE =====
    if isinstance(result, UserFileResponse):
        file_id, file_path = _process_file_response(result)
        return {
            'type': 'download',
            'path': file_path,
            'filename': result.filename
        }

    # ===== DEFAULT: TEXT =====
    return {
        'type': 'text',
        'data': str(result)
    }

func_to_web.types

FileResponse

Bases: BaseModel

Model for file response - accepts either binary data or file path.

Source code in func_to_web\types.py
class FileResponse(BaseModel):
    """Model for file response - accepts either binary data or file path."""
    data: bytes | None = None
    path: str | None = None
    filename: Annotated[str, Field(max_length=150)]

    @model_validator(mode='after')
    def validate_data_or_path(self):
        """Ensure exactly one of data or path is provided."""
        if self.data is None and self.path is None:
            raise ValueError("Either 'data' or 'path' must be provided")
        if self.data is not None and self.path is not None:
            raise ValueError("Cannot provide both 'data' and 'path'")
        return self
validate_data_or_path()

Ensure exactly one of data or path is provided.

Source code in func_to_web\types.py
@model_validator(mode='after')
def validate_data_or_path(self):
    """Ensure exactly one of data or path is provided."""
    if self.data is None and self.path is None:
        raise ValueError("Either 'data' or 'path' must be provided")
    if self.data is not None and self.path is not None:
        raise ValueError("Cannot provide both 'data' and 'path'")
    return self