Is it possible to serve a folder structure?

Hi there. I searched the topics but could not find anything similar.

I have a folder structure that looks something like this (apologies for bad ASCII art):

 |      |____
 |      |____

I know that if I use panel serve --port 8080 --glob dashboards/*.py dashboards/**/*.py, all files will be picked up, and I can access these at http ://localhost:8080/ , http ://localhost:8080/, etc. However the intermediate folder structure is lost.

Is there a way to serve these so that the URLs used to access would match the folder structure? For example http ://localhost:8080/, http ://localhost:8080/A/, and so on.


Hi! I recently asked the exact same question and the answer is that unfortunately no, and that this is a behavior inherited from the Bokeh server.

In the end, I monkeypatched bokeh.command.util.build_single_handler_applications to return the route I wanted, and got it to work that way.

1 Like

Hi @rekcahpassyla

Is it possible for you to share the monkey patching? Thanks.

Sure. Here it is (not exact code as I don’t want to paste in anything proprietary to my work)

# script name is
# calling without any arguments, will load everything in the main/ directory
# calling with a specific script name, that is in the main/ directory,
# For example if your script is main/level1/level2/
# call this with
#python level1/level2/
# and it will run only that script. This allows for faster startup.
import logging
import os
import re
import sys

from typing import Dict, Iterator, List

# import first before importing anything from panel
from bokeh.command import util
from bokeh.application import Application

# this will extract level1/level2/script from the path
# main/level1/level2/ 
APP_RE = re.compile("main/(.*)\.py")

def build_single_handler_applications(paths: List[str], argvs: Dict[str, List[str]] | None = None) -> Dict[str, Application]:
    ''' Return a dictionary mapping routes to Bokeh applications built using
    single handlers, for specified files or directories.

    This function iterates over ``paths`` and ``argvs`` and calls
    :func:`~bokeh.command.util.build_single_handler_application` on each
    to generate the mapping.

        paths (seq[str]) : paths to files or directories for creating Bokeh

        argvs (dict[str, list[str]], optional) : mapping of paths to command
            line arguments to pass to the handler for each path

        dict[str, Application]


    applications: Dict[str, Application] = {}
    argvs = argvs or {}

    for path in paths:
        application = util.build_single_handler_application(path, argvs.get(path, []))

        # custom override for our purposes
        # The original line is as below, which only returns the base name
        # without the directory structure.
        #route = application.handlers[0].url_path()
        # our paths will all be main/**..../*.py
        # so we need to find the bit between main and *.py

        route = "/" + APP_RE.match(path).groups()[0]

        if not route:
            if '/' in applications:
                raise RuntimeError("Don't know the URL path to use for %s" % (path))
            route = '/'
        applications[route] = application

    return applications

# override the import here, so that our function is called instead
sys.modules['bokeh.command.util'].build_single_handler_applications = build_single_handler_applications

def main():
    # panel imports must be done after the monkeypatch of bokeh.command.util
    # so that our monkeypatched function will be taken instead
    from panel.command import main

    sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
    if len(sys.argv) == 1:
        # The below is hardcoded to 4 levels because somewhere in the bokeh code,
        # glob.glob is called without the recursive=True flag
        # Monkeypatching like the above is not a good idea in this case,
        # because glob.glob is a system installed library; monkeypatching
        # would have too great a scope.
        dashboards = [
    elif len(sys.argv) == 2:
        panels = [f"main/{sys.argv[1]}"]
        raise ValueError("Too many arguments to")
    port = os.environ.get("PORT", 8080)
    sys.argv = [sys.argv[0], "serve", "--port", port,  "--glob", "--autoreload"]
    sys.argv += panels"Starting with command line: {sys.argv}")

if __name__ == '__main__':