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):

dashboards
 |___page0.py
 |
 |___A
 |      |____ page1.py
 |      |____ page2.py
 |
 |__B
       |_____page3.py
 ...
    

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/page0.py , http ://localhost:8080/page1.py, 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/page0.py, http ://localhost:8080/A/page1.py, and so on.

Thanks!

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 run.py
#
# calling without any arguments, will load everything in the main/ directory
# calling run_panel.py with a specific script name, that is in the main/ directory,
# For example if your script is main/level1/level2/script.py
# call this with
#
#python run.py level1/level2/script.py
#
# 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/script.py 
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.

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

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

    Returns:
        dict[str, Application]

    Raises:
        RuntimeError

    '''
    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 = [
            "main/*.py",
            "main/**/*.py",
            "main/**/**/*.py",
            "main/**/**/**/*.py",
            "main/**/**/**/**/*.py",
        ]
    elif len(sys.argv) == 2:
        panels = [f"main/{sys.argv[1]}"]
    else:
        raise ValueError("Too many arguments to run.py")
    port = os.environ.get("PORT", 8080)
    sys.argv = [sys.argv[0], "serve", "--port", port,  "--glob", "--autoreload"]
    sys.argv += panels
    logging.info(f"Starting with command line: {sys.argv}")
    sys.exit(main())


if __name__ == '__main__':
    main()

2 Likes