diff --git a/solara/autorouting.py b/solara/autorouting.py index e17cb62be..0affd85f8 100644 --- a/solara/autorouting.py +++ b/solara/autorouting.py @@ -347,17 +347,22 @@ def get_title(module: ModuleType, required=True): return title -def fix_route(route: solara.Route, new_file: Path) -> solara.Route: +def fix_route(route: solara.Route, new_file: Path, new_layout=None) -> solara.Route: file = route.file or new_file + layout = route.layout or new_layout children = fix_routes(route.children, new_file) if route.children else [] - return dataclasses.replace(route, file=file, children=children) + return dataclasses.replace(route, file=file, children=children, layout=layout) -def fix_routes(routes: List[solara.Route], new_file: Path): +def fix_routes(routes: List[solara.Route], new_file: Path, new_layout=None): new_routes = [] for route in routes: - new_routes.append(fix_route(route, new_file)) + if route.path == "/": + route = fix_route(route, new_file, new_layout) + else: + route = fix_route(route, new_file) + new_routes.append(route) return new_routes @@ -378,6 +383,7 @@ def generate_routes(module: ModuleType) -> List[solara.Route]: assert module.__file__ is not None routes = [] + children: List[solara.Route] file = Path(module.__file__) if module.__file__.endswith("__init__.py"): @@ -429,11 +435,19 @@ def generate_routes(module: ModuleType) -> List[solara.Route]: warnings.warn(f"Some routes are not in route_order: {set(lookup) - set(route_order)}") else: - children = getattr(module, "routes", []) - children = fix_routes(children, file) layout = getattr(module, "Layout", None) - # children = [] - # single module, single route + children = [] + if hasattr(module, "routes"): + children = getattr(module, "routes") + children = fix_routes(children, file, layout) + if layout is not None: + root = get_root(children) + if layout and root and root.layout is None: + warnings.warn( + f'You defined routes in {file}, in this case, layout should be set on the root route (with path="/"), not on the module level' + ) + children = fix_routes(children, file, layout) + layout = None return [solara.Route(path="/", component=RenderPage, data=None, module=module, label=get_title(module), layout=layout, children=children, file=file)] return routes @@ -493,7 +507,7 @@ def _generate_route_path(subpath: Path, layout=None, first=False, has_index=Fals route_path = "-".join([k.lower() for k in title_parts]) # used as a 'sentinel' to find the deepest level of the route tree we need to render in 'RenderPage' component = RenderPage - children = [] + children: List[solara.Route] = [] module: Optional[ModuleType] = None data: Any = None module_layout = layout if first else None @@ -506,8 +520,27 @@ def _generate_route_path(subpath: Path, layout=None, first=False, has_index=Fals reload.reloader.watcher.add_file(subpath) module = source_to_module(subpath, initial_namespace=initial_namespace) title = get_title(module) - children = getattr(module, "routes", children) + layout = getattr(module, "Layout", module_layout) + if hasattr(module, "routes"): + children = getattr(module, "routes") + if layout is not None: + root = get_root(children) + if layout and root and root.layout is None: + warnings.warn( + f'You defined routes in {subpath}, in this case, layout should be set on the root route (with path="/"), not on the module level' + ) + children = fix_routes(children, subpath, layout) + layout = None children = fix_routes(children, subpath) - module_layout = getattr(module, "Layout", module_layout) - route = solara.Route(route_path, component=component, module=module, label=title, children=children, data=data, layout=module_layout, file=subpath) + component = get_page(module, required=False) + if children and component and children[0].component: + raise ValueError(f"child routes with a routes[0].component and a Page component are both defined in {subpath}") + route = solara.Route(route_path, component=component, module=module, label=title, children=children, data=data, layout=layout, file=subpath) return route + + +def get_root(routes: List[solara.Route]) -> Optional[solara.Route]: + for route in routes: + if route.path == "/": + return route + return None diff --git a/solara/server/app.py b/solara/server/app.py index 43bf1bf97..e3baed20f 100644 --- a/solara/server/app.py +++ b/solara/server/app.py @@ -5,6 +5,7 @@ import sys import threading import traceback +import warnings from enum import Enum from pathlib import Path from typing import Any, Dict, List, Optional, cast @@ -148,10 +149,17 @@ def add_path(): mod = importlib.import_module(self.name) routes = solara.generate_routes(mod) - app = solara.autorouting.RenderPage(self.app_name) - if not hasattr(routes[0].module, self.app_name) and routes[0].children: + # when the root moduled defined routes, skip the enclosing route object + if len(routes) == 1 and routes[0].module and hasattr(routes[0].module, "routes"): + if routes[0].component: + warnings.warn( + f"{self.name} has a component defined, but you are also defining routes." + " To avoid confusing, consider renaming the {self.app_name} component." + ) routes = routes[0].children + app = solara.autorouting.RenderPage(self.app_name) + if settings.ssg.build_path is None: settings.ssg.build_path = self.directory.parent.resolve() / "build" diff --git a/tests/unit/autorouting_test.py b/tests/unit/autorouting_test.py index 2a672d15d..ce1c88ba6 100644 --- a/tests/unit/autorouting_test.py +++ b/tests/unit/autorouting_test.py @@ -11,8 +11,9 @@ import solara.website.pages.examples import solara.widgets from solara.components.title import TitleWidget +from solara.server.app import AppScript -HERE = Path(__file__) +HERE = Path(__file__).parent def test_count_arguments(): @@ -133,7 +134,7 @@ def test_routes_examples_docs(): def test_routes_directory(): - routes = solara.autorouting.generate_routes_directory(HERE.parent / "solara_test_apps" / "multipage") + routes = solara.autorouting.generate_routes_directory(HERE / "solara_test_apps" / "multipage") assert len(routes) == 8 assert routes[0].path == "/" assert routes[0].label == "Home" @@ -221,7 +222,7 @@ def test_routes_directory(): def test_routes_regular_widgets(): # routes = solara.autorouting.generate_routes_directory(HERE.parent / "solara_test_apps" / "multipage") - routes = solara.autorouting.generate_routes_directory(HERE.parent / "solara_test_apps" / "multipage-widgets") + routes = solara.autorouting.generate_routes_directory(HERE / "solara_test_apps" / "multipage-widgets") main_object = solara.autorouting.RenderPage() solara_context = solara.RoutingProvider(children=[main_object], routes=routes, pathname="/") @@ -253,3 +254,47 @@ def test_routes_regular_widgets(): assert rc.find(widgets.Button).widget.description == "Viewed 1 times" nav.location = "/volume" assert rc.find(v.Slider).widget.v_model == 5 + + +def test_single_file_routes_as_file(kernel_context, no_kernel_context): + name = str(HERE / "solara_test_apps" / "single_file_routes.py") + app = AppScript(name) + assert len(app.routes) == 2 + assert app.routes[0].path == "/" + assert app.routes[0].layout is not None + assert app.routes[1].path == "page2" + assert app.routes[1].layout is None + try: + with kernel_context: + el = app.run() + root = solara.RoutingProvider(children=[el], routes=app.routes, pathname="/") + _box, rc = solara.render(root, handle_error=False) + rc.find(children=["Page 1"]).assert_single() + rc.find(children=["Custom layout"]).assert_single() + rc.render(solara.RoutingProvider(children=[el], routes=app.routes, pathname="/page2").key("2")) + rc.find(children=["Custom layout"]).assert_single() + rc.find(children=["Page 2"]).assert_single() + finally: + app.close() + + +def test_single_file_routes_as_module(kernel_context, no_kernel_context, extra_include_path): + with extra_include_path(HERE / "solara_test_apps"): + app = AppScript("single_file_routes") + assert len(app.routes) == 2 + assert app.routes[0].path == "/" + assert app.routes[0].layout is not None + assert app.routes[1].path == "page2" + assert app.routes[1].layout is None + try: + with kernel_context: + el = app.run() + root = solara.RoutingProvider(children=[el], routes=app.routes, pathname="/") + _box, rc = solara.render(root, handle_error=False) + rc.find(children=["Page 1"]).assert_single() + rc.find(children=["Custom layout"]).assert_single() + rc.render(solara.RoutingProvider(children=[el], routes=app.routes, pathname="/page2").key("2")) + rc.find(children=["Custom layout"]).assert_single() + rc.find(children=["Page 2"]).assert_single() + finally: + app.close() diff --git a/tests/unit/solara_test_apps/single_file_routes.py b/tests/unit/solara_test_apps/single_file_routes.py new file mode 100644 index 000000000..c4458f3ce --- /dev/null +++ b/tests/unit/solara_test_apps/single_file_routes.py @@ -0,0 +1,25 @@ +import solara + + +@solara.component +def Page1(): + solara.Text("Page 1") + + +@solara.component +def Page2(): + solara.Text("Page 2") + + +@solara.component +def Layout(children): + with solara.AppLayout(): + with solara.Column(): + solara.Text("Custom layout") + solara.display(*children) + + +routes = [ + solara.Route("/", component=Page1, layout=Layout), + solara.Route("page2", component=Page2), +]