From e686be35c1034c183e062bd4650021bde5ff5832 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Tue, 14 Jul 2026 15:08:12 -0500 Subject: [PATCH 1/2] Add "tethys run" command for zero-config single-file apps (express mode) Proof-of-concept, Shiny-inspired runner: "tethys run app.py" serves a single-file component app with no portal configuration, no database setup, no pip install, and no login. It generates an isolated TETHYS_HOME (~/.tethys/express/_/) containing a portal config (single-app mode + open portal) and a throwaway SQLite database, grafts the app file into the tethysapp namespace via sys.modules, and launches the standard development server. - tethys_cli/run_commands.py: new "run" subcommand (-p/--port, --host, --no-browser, --no-reload, --clean) - tethys_apps/base/express.py: express loader + metadata synthesis (package/name/root_url/index derived from the file when omitted) - tethys_apps/harvester.py: include the express app during harvest - tethys_apps/base/component_base.py: __init_subclass__ hook for express metadata; fix auto nav links in single-app mode (/apps// 404s when MULTIPLE_APP_MODE=False) - tethys_apps/utilities.py: catch sqlite OperationalError in get_configured_standalone_app (fresh single-app portals crashed during migrate when reactpy_django imports the URLconf) Co-Authored-By: Claude Fable 5 --- .../test_base/test_express.py | 230 ++++++++++++++++++ .../test_tethys_cli/test_run_commands.py | 201 +++++++++++++++ tethys_apps/base/component_base.py | 10 +- tethys_apps/base/express.py | 230 ++++++++++++++++++ tethys_apps/harvester.py | 7 + tethys_apps/utilities.py | 4 +- tethys_cli/__init__.py | 2 + tethys_cli/run_commands.py | 183 ++++++++++++++ 8 files changed, 864 insertions(+), 3 deletions(-) create mode 100644 tests/unit_tests/test_tethys_apps/test_base/test_express.py create mode 100644 tests/unit_tests/test_tethys_cli/test_run_commands.py create mode 100644 tethys_apps/base/express.py create mode 100644 tethys_cli/run_commands.py diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_express.py b/tests/unit_tests/test_tethys_apps/test_base/test_express.py new file mode 100644 index 000000000..10980add2 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_base/test_express.py @@ -0,0 +1,230 @@ +import sys +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest import mock + +from tethys_apps.base import express + +COMPONENT_APP_SOURCE = """ +from tethys_sdk.components import ComponentBase + + +class App(ComponentBase): + pass + + +@App.page +def home(lib): + return None + + +@App.page +def another_page(lib): + return None +""" + +COMPONENT_APP_WITH_METADATA_SOURCE = """ +from tethys_sdk.components import ComponentBase + + +class App(ComponentBase): + name = "My Custom Name" + package = "custom_package" + root_url = "custom-root-url" + index = "custom_index" + + +@App.page +def custom_index(lib): + return None +""" + +NOT_A_COMPONENT_APP_SOURCE = """ +class NotAnApp: + pass +""" + + +class TestExpressHelpers(unittest.TestCase): + def setUp(self): + self.temp_dir = TemporaryDirectory() + self.temp_path = Path(self.temp_dir.name) + + def tearDown(self): + self.temp_dir.cleanup() + + def write_app_file(self, source, name="test_dashboard.py"): + app_file = self.temp_path / name + app_file.write_text(source) + return app_file + + def test_get_express_app_file_not_set(self): + with mock.patch.dict(express.environ, {}, clear=True): + self.assertIsNone(express.get_express_app_file()) + + def test_get_express_app_file_set(self): + app_file = self.write_app_file(COMPONENT_APP_SOURCE) + with mock.patch.dict( + express.environ, {express.TETHYS_EXPRESS_APP_ENV: str(app_file)} + ): + self.assertEqual(app_file.resolve(), express.get_express_app_file()) + + def test_find_component_app_class_node(self): + app_file = self.write_app_file(COMPONENT_APP_SOURCE) + node = express.find_component_app_class_node(app_file) + self.assertIsNotNone(node) + self.assertEqual("App", node.name) + + def test_find_component_app_class_node_not_component_app(self): + app_file = self.write_app_file(NOT_A_COMPONENT_APP_SOURCE) + self.assertIsNone(express.find_component_app_class_node(app_file)) + + def test_find_component_app_class_node_invalid_syntax(self): + app_file = self.write_app_file("def broken(:\n") + self.assertIsNone(express.find_component_app_class_node(app_file)) + + def test_find_component_app_class_node_missing_file(self): + self.assertIsNone( + express.find_component_app_class_node(self.temp_path / "no_such.py") + ) + + def test_derive_package_name(self): + self.assertEqual( + "my_dashboard", express.derive_package_name(Path("my-dashboard.py")) + ) + + def test_derive_package_name_leading_digit(self): + self.assertEqual("app_2cool", express.derive_package_name(Path("2cool.py"))) + + def test_derive_package_name_generic_app_uses_parent_dir(self): + self.assertEqual( + "flood_viewer", + express.derive_package_name(Path("/tmp/Flood Viewer/app.py")), + ) + + def test_get_express_package_name_derived(self): + app_file = self.write_app_file(COMPONENT_APP_SOURCE) + self.assertEqual("test_dashboard", express.get_express_package_name(app_file)) + + def test_get_express_package_name_explicit(self): + app_file = self.write_app_file(COMPONENT_APP_WITH_METADATA_SOURCE) + self.assertEqual("custom_package", express.get_express_package_name(app_file)) + + +class TestSynthesizeExpressMetadata(unittest.TestCase): + def setUp(self): + self.temp_dir = TemporaryDirectory() + self.app_file = Path(self.temp_dir.name) / "test_dashboard.py" + self.app_file.write_text(COMPONENT_APP_SOURCE) + + self.fake_module = mock.MagicMock(__file__=str(self.app_file)) + sys.modules["_test_express_module"] = self.fake_module + + class DummyApp: + __module__ = "_test_express_module" + name = "" + package = "" + root_url = "" + index = "" + + self.DummyApp = DummyApp + + def tearDown(self): + del sys.modules["_test_express_module"] + self.temp_dir.cleanup() + + def test_not_express_mode(self): + with mock.patch.dict(express.environ, {}, clear=True): + express.synthesize_express_metadata(self.DummyApp) + self.assertEqual("", self.DummyApp.package) + + def test_synthesizes_missing_metadata(self): + with mock.patch.dict( + express.environ, {express.TETHYS_EXPRESS_APP_ENV: str(self.app_file)} + ): + express.synthesize_express_metadata(self.DummyApp) + self.assertEqual("test_dashboard", self.DummyApp.package) + self.assertEqual("Test Dashboard", self.DummyApp.name) + self.assertEqual("test-dashboard", self.DummyApp.root_url) + self.assertEqual("/", self.DummyApp.exit_url) + self.assertEqual("NavHeader", self.DummyApp.default_layout) + self.assertEqual("auto", self.DummyApp.nav_links) + + def test_does_not_override_existing_metadata(self): + self.DummyApp.name = "Existing Name" + self.DummyApp.package = "existing_package" + self.DummyApp.default_layout = None + with mock.patch.dict( + express.environ, {express.TETHYS_EXPRESS_APP_ENV: str(self.app_file)} + ): + express.synthesize_express_metadata(self.DummyApp) + self.assertEqual("Existing Name", self.DummyApp.name) + self.assertEqual("existing_package", self.DummyApp.package) + self.assertIsNone(self.DummyApp.default_layout) + + def test_ignores_classes_from_other_modules(self): + other_file = Path(self.temp_dir.name) / "other.py" + other_file.write_text(COMPONENT_APP_SOURCE) + with mock.patch.dict( + express.environ, {express.TETHYS_EXPRESS_APP_ENV: str(other_file)} + ): + express.synthesize_express_metadata(self.DummyApp) + self.assertEqual("", self.DummyApp.package) + + +class TestHarvestExpressApp(unittest.TestCase): + def setUp(self): + self.temp_dir = TemporaryDirectory() + self.app_file = Path(self.temp_dir.name) / "test_express_harvest.py" + self.app_file.write_text(COMPONENT_APP_SOURCE) + + def tearDown(self): + for name in list(sys.modules): + if "test_express_harvest" in name: + del sys.modules[name] + + from tethys_apps.base.component_base import AppSingleton + + AppSingleton._instances.pop("test_express_harvest", None) + self.temp_dir.cleanup() + + def test_not_express_mode(self): + with mock.patch.dict(express.environ, {}, clear=True): + self.assertIsNone(express.harvest_express_app()) + + def test_loads_and_registers_app(self): + with mock.patch.dict( + express.environ, {express.TETHYS_EXPRESS_APP_ENV: str(self.app_file)} + ): + package = express.harvest_express_app() + + self.assertEqual("test_express_harvest", package) + self.assertIn("tethysapp.test_express_harvest", sys.modules) + self.assertIn("tethysapp.test_express_harvest.app", sys.modules) + + module = sys.modules["tethysapp.test_express_harvest.app"] + self.assertEqual("test_express_harvest", module.App.package) + # Index defaults to the first page defined in the file + self.assertEqual("home", module.App.index) + + def test_load_is_idempotent(self): + with mock.patch.dict( + express.environ, {express.TETHYS_EXPRESS_APP_ENV: str(self.app_file)} + ): + express.harvest_express_app() + first_module = sys.modules["tethysapp.test_express_harvest.app"] + express.harvest_express_app() + self.assertIs( + first_module, sys.modules["tethysapp.test_express_harvest.app"] + ) + + def test_no_app_class_exits(self): + self.app_file.write_text(NOT_A_COMPONENT_APP_SOURCE) + with mock.patch.dict( + express.environ, {express.TETHYS_EXPRESS_APP_ENV: str(self.app_file)} + ): + with self.assertRaises(SystemExit): + express.harvest_express_app() + self.assertNotIn("tethysapp.test_express_harvest", sys.modules) + self.assertNotIn("tethysapp.test_express_harvest.app", sys.modules) diff --git a/tests/unit_tests/test_tethys_cli/test_run_commands.py b/tests/unit_tests/test_tethys_cli/test_run_commands.py new file mode 100644 index 000000000..4a96a69ee --- /dev/null +++ b/tests/unit_tests/test_tethys_cli/test_run_commands.py @@ -0,0 +1,201 @@ +import sys +import unittest +from argparse import Namespace +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest import mock + +import yaml + +from tethys_cli.run_commands import run_command, write_portal_config + +COMPONENT_APP_SOURCE = """ +from tethys_sdk.components import ComponentBase + + +class App(ComponentBase): + pass + + +@App.page +def home(lib): + return None +""" + + +def make_args(**kwargs): + defaults = dict( + app_file="app.py", + port=8000, + host="127.0.0.1", + no_browser=True, + no_reload=False, + clean=False, + ) + defaults.update(kwargs) + return Namespace(**defaults) + + +class TestRunCommand(unittest.TestCase): + def setUp(self): + self.temp_dir = TemporaryDirectory() + self.temp_path = Path(self.temp_dir.name) + self.app_file = self.temp_path / "test_dashboard.py" + self.app_file.write_text(COMPONENT_APP_SOURCE) + self.tethys_home = self.temp_path / "tethys_home" + + home_patcher = mock.patch( + "tethys_apps.utilities.get_tethys_home_dir", + return_value=str(self.tethys_home), + ) + home_patcher.start() + self.addCleanup(home_patcher.stop) + + manage_patcher = mock.patch( + "tethys_cli.run_commands.get_manage_path", + return_value="path/to/manage.py", + ) + self.mock_get_manage_path = manage_patcher.start() + self.addCleanup(manage_patcher.stop) + + subprocess_run_patcher = mock.patch("tethys_cli.run_commands.subprocess.run") + self.mock_subprocess_run = subprocess_run_patcher.start() + self.mock_subprocess_run.return_value = mock.MagicMock(returncode=0) + self.addCleanup(subprocess_run_patcher.stop) + + subprocess_call_patcher = mock.patch("tethys_cli.run_commands.subprocess.call") + self.mock_subprocess_call = subprocess_call_patcher.start() + self.addCleanup(subprocess_call_patcher.stop) + + timer_patcher = mock.patch("tethys_cli.run_commands.threading.Timer") + self.mock_timer = timer_patcher.start() + self.addCleanup(timer_patcher.stop) + + def tearDown(self): + self.temp_dir.cleanup() + + @property + def express_home(self): + express_dir = self.tethys_home / "express" + homes = list(express_dir.glob("test_dashboard_*")) + return homes[0] if homes else None + + @mock.patch("tethys_cli.run_commands.write_error") + def test_missing_file(self, mock_write_error): + args = make_args(app_file=str(self.temp_path / "does_not_exist.py")) + with self.assertRaises(SystemExit): + run_command(args) + mock_write_error.assert_called_once() + + @mock.patch("tethys_cli.run_commands.write_error") + def test_not_a_component_app(self, mock_write_error): + self.app_file.write_text("x = 1\n") + args = make_args(app_file=str(self.app_file)) + with self.assertRaises(SystemExit): + run_command(args) + mock_write_error.assert_called_once() + + def test_happy_path(self): + args = make_args(app_file=str(self.app_file)) + run_command(args) + + # Express home created with generated portal config + express_home = self.express_home + self.assertIsNotNone(express_home) + config = yaml.safe_load((express_home / "portal_config.yml").read_text()) + portal_config = config["settings"]["TETHYS_PORTAL_CONFIG"] + self.assertFalse(portal_config["MULTIPLE_APP_MODE"]) + self.assertEqual("test_dashboard", portal_config["STANDALONE_APP"]) + self.assertTrue(portal_config["ENABLE_OPEN_PORTAL"]) + + # Database migrated + migrate_call = self.mock_subprocess_run.call_args + self.assertEqual( + [sys.executable, "path/to/manage.py", "migrate", "--no-input"], + migrate_call.args[0], + ) + migrate_env = migrate_call.kwargs["env"] + self.assertEqual(str(express_home), migrate_env["TETHYS_HOME"]) + self.assertEqual( + str(self.app_file.resolve()), migrate_env["TETHYS_EXPRESS_APP"] + ) + + # Server started + server_call = self.mock_subprocess_call.call_args + self.assertEqual( + [sys.executable, "path/to/manage.py", "runserver", "127.0.0.1:8000"], + server_call.args[0], + ) + self.assertEqual(str(express_home), server_call.kwargs["env"]["TETHYS_HOME"]) + + # No browser requested + self.mock_timer.assert_not_called() + + def test_no_reload_and_custom_port(self): + args = make_args(app_file=str(self.app_file), no_reload=True, port=8080) + run_command(args) + server_call = self.mock_subprocess_call.call_args + self.assertEqual( + [ + sys.executable, + "path/to/manage.py", + "runserver", + "--noreload", + "127.0.0.1:8080", + ], + server_call.args[0], + ) + + def test_opens_browser(self): + args = make_args(app_file=str(self.app_file), no_browser=False) + run_command(args) + self.mock_timer.assert_called_once() + self.assertEqual( + ["http://127.0.0.1:8000/"], self.mock_timer.call_args.kwargs["args"] + ) + + def test_clean_removes_existing_state(self): + args = make_args(app_file=str(self.app_file)) + run_command(args) + express_home = self.express_home + sentinel = express_home / "sentinel.txt" + sentinel.write_text("stale") + + args = make_args(app_file=str(self.app_file), clean=True) + run_command(args) + self.assertFalse(sentinel.exists()) + + @mock.patch("tethys_cli.run_commands.write_error") + def test_migrate_failure(self, mock_write_error): + self.mock_subprocess_run.return_value = mock.MagicMock( + returncode=1, stdout="out", stderr="err" + ) + args = make_args(app_file=str(self.app_file)) + with self.assertRaises(SystemExit): + run_command(args) + mock_write_error.assert_called_once() + self.mock_subprocess_call.assert_not_called() + + def test_secret_key_preserved_across_runs(self): + args = make_args(app_file=str(self.app_file)) + run_command(args) + config_path = self.express_home / "portal_config.yml" + first_key = yaml.safe_load(config_path.read_text())["settings"]["SECRET_KEY"] + + run_command(args) + second_key = yaml.safe_load(config_path.read_text())["settings"]["SECRET_KEY"] + self.assertEqual(first_key, second_key) + + +class TestWritePortalConfig(unittest.TestCase): + def test_write_portal_config(self): + with TemporaryDirectory() as temp_dir: + config_path = Path(temp_dir) / "portal_config.yml" + write_portal_config(config_path, "my_app") + config = yaml.safe_load(config_path.read_text()) + self.assertEqual( + "my_app", + config["settings"]["TETHYS_PORTAL_CONFIG"]["STANDALONE_APP"], + ) + self.assertTrue(config["settings"]["SECRET_KEY"]) + self.assertEqual(["*"], config["settings"]["ALLOWED_HOSTS"]) diff --git a/tethys_apps/base/component_base.py b/tethys_apps/base/component_base.py index d0ac880c7..4c1e9ed81 100644 --- a/tethys_apps/base/component_base.py +++ b/tethys_apps/base/component_base.py @@ -1,5 +1,6 @@ from tethys_apps.base.app_base import TethysAppBase from tethys_apps.base.controller import page as page_controller +from django.conf import settings from django.templatetags.static import static @@ -40,6 +41,13 @@ class ComponentBase(TethysAppBase, metaclass=AppSingleton): """ + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + # Fill in metadata for single-file apps run via "tethys run" (express mode) + from tethys_apps.base.express import synthesize_express_metadata + + synthesize_express_metadata(cls) + @classmethod def get_static_url(cls, *args): url = static(cls.package) @@ -58,7 +66,7 @@ def navigation_links(self): self.registered_url_maps, key=lambda x: x.index if x.index is not None else 999, ): - href = f"/apps/{self.root_url}/" + href = f"/apps/{self.root_url}/" if settings.MULTIPLE_APP_MODE else "/" if url_map.name != self.index: href += url_map.name.replace("_", "-") + "/" if url_map.index == -1: diff --git a/tethys_apps/base/express.py b/tethys_apps/base/express.py new file mode 100644 index 000000000..2513100e8 --- /dev/null +++ b/tethys_apps/base/express.py @@ -0,0 +1,230 @@ +""" +******************************************************************************** +* Name: express.py +* Author: Gage Larsen +* Created On: July 2026 +* Copyright: +* License: BSD 2-Clause +******************************************************************************** +""" + +import ast +import inspect +import logging +import re +import sys +from importlib.machinery import ModuleSpec +from importlib.util import module_from_spec, spec_from_file_location +from os import environ +from pathlib import Path + +TETHYS_EXPRESS_APP_ENV = "TETHYS_EXPRESS_APP" + +tethys_log = logging.getLogger("tethys." + __name__) + + +def get_express_app_file(): + """ + Get the path to the single-file app being run by ``tethys run``, if any. + + Returns: + Path or None: resolved path to the app file, or None if not in express mode. + """ + value = environ.get(TETHYS_EXPRESS_APP_ENV) + return Path(value).resolve() if value else None + + +def find_component_app_class_node(app_file): + """ + Find the AST node of the ComponentBase subclass defined in the given file. + + Returns: + ast.ClassDef or None: the class definition node, or None if no such class is found. + """ + try: + tree = ast.parse(Path(app_file).read_text()) + except (OSError, SyntaxError): + return None + + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + for base in node.bases: + base_name = ( + base.attr + if isinstance(base, ast.Attribute) + else getattr(base, "id", None) + ) + if base_name == "ComponentBase": + return node + return None + + +def _source_stem(app_file): + """ + Get the name-worthy stem for the app file. Uses the parent directory name for + generically-named files (e.g. app.py) so "dashboards/app.py" is named "dashboards". + """ + app_file = Path(app_file) + if app_file.stem == "app" and app_file.parent.name: + return app_file.parent.name + return app_file.stem + + +def derive_package_name(app_file): + """ + Derive a valid app package name from the app file name (e.g. my-dashboard.py -> my_dashboard). + """ + package = re.sub(r"\W", "_", _source_stem(app_file)).lower() + if package[0].isdigit(): + package = f"app_{package}" + return package + + +def get_express_package_name(app_file): + """ + Get the package name of the express app. An explicit ``package`` attribute on the + app class wins, otherwise it is derived from the file name. + """ + class_node = find_component_app_class_node(app_file) + if class_node is not None: + for statement in class_node.body: + if ( + isinstance(statement, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "package" + for target in statement.targets + ) + and isinstance(statement.value, ast.Constant) + and isinstance(statement.value.value, str) + ): + return statement.value.value + return derive_package_name(app_file) + + +def synthesize_express_metadata(app_class): + """ + Fill in required app metadata on the express app class so a bare single-file app can + omit it. Called by ``ComponentBase.__init_subclass__`` when the class is defined, which + ensures the metadata is in place before any ``@App.page`` decorators are evaluated. + No-op unless the class is defined in the file being run by ``tethys run``. + """ + app_file = get_express_app_file() + if app_file is None: + return + + module = sys.modules.get(app_class.__module__) + module_file = getattr(module, "__file__", None) + if module_file is None or Path(module_file).resolve() != app_file: + return + + if not app_class.package: + app_class.package = derive_package_name(app_file) + if not app_class.name: + app_class.name = ( + _source_stem(app_file).replace("_", " ").replace("-", " ").title() + ) + if not app_class.root_url: + app_class.root_url = app_class.package.replace("_", "-") + if not getattr(app_class, "exit_url", None): + app_class.exit_url = "/" + if not hasattr(app_class, "default_layout"): + app_class.default_layout = "NavHeader" + if not hasattr(app_class, "nav_links"): + app_class.nav_links = "auto" + + +def harvest_express_app(): + """ + Load the single-file app pointed to by the TETHYS_EXPRESS_APP environment variable and + register it under the ``tethysapp`` namespace so the harvester can find it. + + Returns: + str or None: the app package name, or None if not running in express mode. + """ + app_file = get_express_app_file() + if app_file is None: + return None + + _ensure_tethysapp_namespace() + + package = get_express_package_name(app_file) + module_name = f"tethysapp.{package}.app" + if module_name in sys.modules: + return package + + try: + _load_express_module(package, module_name, app_file) + except Exception: + for name in (module_name, f"tethysapp.{package}"): + sys.modules.pop(name, None) + tethys_log.exception( + f'Express app "{app_file}" not loaded because of the following error:' + ) + print( + f'\033[91mError: The app "{app_file}" could not be loaded. ' + f"See the error above for details.\033[0m" + ) + raise SystemExit(1) + + return package + + +def _ensure_tethysapp_namespace(): + """ + Make ``import tethysapp`` work even when no apps are installed in the environment. + """ + try: + import tethysapp # noqa: F401 + except ImportError: + namespace_spec = ModuleSpec("tethysapp", None, is_package=True) + sys.modules["tethysapp"] = module_from_spec(namespace_spec) + + +def _load_express_module(package, module_name, app_file): + """ + Load the app file as the module ``tethysapp..app`` and register it (and a + synthetic parent package) in ``sys.modules`` so it imports and reloads like an + installed app. + """ + # import here to prevent circular imports + from tethys_apps.base import controller as controller_module + from tethys_apps.base.component_base import ComponentBase + + package_name = f"tethysapp.{package}" + package_spec = ModuleSpec(package_name, None, is_package=True) + package_spec.submodule_search_locations = [str(app_file.parent)] + package_module = module_from_spec(package_spec) + + module_spec = spec_from_file_location(module_name, app_file) + module = module_from_spec(module_spec) + + sys.modules[package_name] = package_module + sys.modules[module_name] = module + + controllers_before = len(controller_module.app_controllers_list) + module_spec.loader.exec_module(module) + package_module.app = module + + app_class = None + for _, obj in inspect.getmembers(module, inspect.isclass): + if ( + issubclass(obj, ComponentBase) + and obj is not ComponentBase + and obj.__module__ == module_name + ): + app_class = obj + break + + if app_class is None: + raise TypeError( + f'No app class found in "{app_file}". A Tethys express app must define a ' + f"class that subclasses ComponentBase (from tethys_sdk.components)." + ) + + # Default the index to the first page defined in the file + if not app_class.index: + registered_pages = controller_module.app_controllers_list[controllers_before:] + if registered_pages: + app_class.index = registered_pages[0]["name"] + + return app_class diff --git a/tethys_apps/harvester.py b/tethys_apps/harvester.py index 60d67645b..75ae148d0 100644 --- a/tethys_apps/harvester.py +++ b/tethys_apps/harvester.py @@ -71,6 +71,10 @@ def harvest_apps(self): if not is_testing_environment(): print(self.BLUE + "Loading Tethys Apps..." + self.ENDC) + from tethys_apps.base.express import harvest_express_app + + express_app_package = harvest_express_app() + import tethysapp tethys_apps = dict() @@ -78,6 +82,9 @@ def harvest_apps(self): if ispkg: tethys_apps[modname] = "tethysapp.{}".format(modname) + if express_app_package: + tethys_apps[express_app_package] = f"tethysapp.{express_app_package}" + # Harvest App Instances self._harvest_app_instances(tethys_apps) diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index eb0a3e1ce..7bf31a37c 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -30,7 +30,7 @@ ) from tethys_apps.exceptions import TethysAppSettingNotAssigned from .harvester import SingletonHarvester -from django.db.utils import ProgrammingError +from django.db.utils import OperationalError, ProgrammingError tethys_log = logging.getLogger("tethys." + __name__) @@ -682,7 +682,7 @@ def get_configured_standalone_app(): app = TethysApp.objects.get(package=standalone_app) else: app = TethysApp.objects.first() - except (ProgrammingError, TethysApp.DoesNotExist): + except (OperationalError, ProgrammingError, TethysApp.DoesNotExist): # If a tethys application is not actually installed or DB is not setup yet, continue and the UI will notify the user pass diff --git a/tethys_cli/__init__.py b/tethys_cli/__init__.py index 097ebb029..2e8c72619 100644 --- a/tethys_cli/__init__.py +++ b/tethys_cli/__init__.py @@ -20,6 +20,7 @@ from tethys_cli.list_command import add_list_parser from tethys_cli.manage_commands import add_manage_parser from tethys_cli.paths_commands import add_paths_parser +from tethys_cli.run_commands import add_run_parser from tethys_cli.scaffold_commands import add_scaffold_parser from tethys_cli.scheduler_commands import add_scheduler_parser from tethys_cli.services_commands import add_services_parser @@ -54,6 +55,7 @@ def tethys_command_parser(): add_list_parser(subparsers) add_manage_parser(subparsers) add_paths_parser(subparsers) + add_run_parser(subparsers) add_scaffold_parser(subparsers) add_scheduler_parser(subparsers) add_services_parser(subparsers) diff --git a/tethys_cli/run_commands.py b/tethys_cli/run_commands.py new file mode 100644 index 000000000..ab2aeec28 --- /dev/null +++ b/tethys_cli/run_commands.py @@ -0,0 +1,183 @@ +""" +******************************************************************************** +* Name: run_commands.py +* Author: Gage Larsen +* Created On: July 2026 +* Copyright: +* License: BSD 2-Clause +******************************************************************************** +""" + +import os +import secrets +import shutil +import subprocess +import sys +import threading +import webbrowser +from hashlib import sha256 +from importlib.util import find_spec +from pathlib import Path + +import yaml + +from tethys_cli.cli_colors import write_error, write_info, write_success +from tethys_cli.cli_helpers import get_manage_path + + +def add_run_parser(subparsers): + # Setup run command + run_parser = subparsers.add_parser( + "run", + help="Run a single-file Tethys app without configuring a portal (express mode).", + ) + run_parser.add_argument( + "app_file", + nargs="?", + default="app.py", + help='Path to the single-file app to run. Defaults to "app.py" in the current directory.', + ) + run_parser.add_argument( + "-p", + "--port", + type=int, + default=8000, + help="Port on which to serve the app. Defaults to 8000.", + ) + run_parser.add_argument( + "--host", + default="127.0.0.1", + help="Host on which to serve the app. Defaults to 127.0.0.1.", + ) + run_parser.add_argument( + "--no-browser", + action="store_true", + help="Do not open the app in a web browser after starting the server.", + ) + run_parser.add_argument( + "--no-reload", + action="store_true", + help="Do not restart the server when the app file changes.", + ) + run_parser.add_argument( + "--clean", + action="store_true", + help="Discard the app's saved state (database and generated config) before running.", + ) + run_parser.set_defaults(func=run_command) + + +def run_command(args): + """ + Run a single-file component app with zero configuration (express mode). Generates an + isolated TETHYS_HOME with a portal config (single-app mode, open portal) and a SQLite + database for the app, then starts the development server pointed at it. + """ + # import here so the CLI works without a configured Django settings module + from tethys_apps.base.express import ( + TETHYS_EXPRESS_APP_ENV, + find_component_app_class_node, + get_express_package_name, + ) + from tethys_apps.utilities import get_tethys_home_dir + + app_file = Path(args.app_file).resolve() + if not app_file.is_file(): + write_error(f'Cannot find the file "{app_file}".') + exit(1) + + if find_component_app_class_node(app_file) is None: + write_error( + f'No app class found in "{app_file}". A Tethys express app must define a ' + "class that subclasses ComponentBase (from tethys_sdk.components)." + ) + exit(1) + + if find_spec("reactpy_django") is None: + write_error( + 'The "tethys run" command requires the "reactpy" and "reactpy_django" packages. ' + 'Install them and try again (e.g. "pip install reactpy-django").' + ) + exit(1) + + package = get_express_package_name(app_file) + app_file_hash = sha256(str(app_file).encode()).hexdigest()[:8] + express_home = ( + Path(get_tethys_home_dir()) / "express" / f"{package}_{app_file_hash}" + ) + + if args.clean and express_home.exists(): + shutil.rmtree(express_home) + write_info(f'Removed saved state for "{app_file.name}".') + + express_home.mkdir(parents=True, exist_ok=True) + write_portal_config(express_home / "portal_config.yml", package) + + env = os.environ.copy() + env["TETHYS_HOME"] = str(express_home) + env[TETHYS_EXPRESS_APP_ENV] = str(app_file) + + manage_path = get_manage_path(args) + + database_path = express_home / "tethys_platform.sqlite" + if not database_path.exists(): + write_info("Initializing app environment (first run only)...") + result = subprocess.run( + [sys.executable, manage_path, "migrate", "--no-input"], + env=env, + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(result.stdout) + print(result.stderr, file=sys.stderr) + # Remove the database so the next run starts from a clean slate + database_path.unlink(missing_ok=True) + write_error( + "Failed to initialize the app environment. See output above for details." + ) + exit(1) + + url = f"http://{args.host}:{args.port}/" + write_success(f'Running "{app_file.name}" at {url} (CTRL+C to quit)') + + if not args.no_browser: + threading.Timer(2, webbrowser.open, args=[url]).start() + + command = [sys.executable, manage_path, "runserver"] + if args.no_reload: + command.append("--noreload") + command.append(f"{args.host}:{args.port}") + + try: + subprocess.call(command, env=env) + except KeyboardInterrupt: + pass + + +def write_portal_config(config_path, package): + """ + Write the generated express-mode portal config, preserving the SECRET_KEY across runs + so sessions survive server restarts. + """ + existing_secret_key = None + if config_path.exists(): + existing_config = yaml.safe_load(config_path.read_text()) or {} + existing_secret_key = (existing_config.get("settings") or {}).get("SECRET_KEY") + + config = { + "version": 2.0, + "name": f"{package} (tethys express)", + "apps": {}, + "settings": { + "SECRET_KEY": existing_secret_key or secrets.token_urlsafe(48), + "DEBUG": True, + "ALLOWED_HOSTS": ["*"], + "TETHYS_PORTAL_CONFIG": { + "MULTIPLE_APP_MODE": False, + "STANDALONE_APP": package, + "ENABLE_OPEN_PORTAL": True, + }, + }, + } + config_path.write_text(yaml.safe_dump(config)) From 35617c4f148a0ab2c81f6f7e51ed31ec8c7b1bc4 Mon Sep 17 00:00:00 2001 From: Gage Larsen Date: Tue, 14 Jul 2026 15:18:54 -0500 Subject: [PATCH 2/2] Add documentation for the tethys run command - docs/tethys_cli/run.rst: full reference page (quick start, how it works, auto-generated arguments via sphinx-argparse, examples) - docs/tethys_cli.rst: add run to the CLI toctree - docs/whats_new.rst: release note entry for express mode - docs/tethys_sdk/components.rst: tip cross-referencing tethys run from the component app.py docs Docs build verified locally with sphinx (no warnings from these files). Co-Authored-By: Claude Fable 5 --- docs/tethys_cli.rst | 1 + docs/tethys_cli/run.rst | 80 ++++++++++++++++++++++++++++++++++ docs/tethys_sdk/components.rst | 4 ++ docs/whats_new.rst | 9 ++++ 4 files changed, 94 insertions(+) create mode 100644 docs/tethys_cli/run.rst diff --git a/docs/tethys_cli.rst b/docs/tethys_cli.rst index fa2df66b9..3684ba74b 100644 --- a/docs/tethys_cli.rst +++ b/docs/tethys_cli.rst @@ -27,6 +27,7 @@ Command Line Interface tethys_cli/list tethys_cli/manage tethys_cli/paths + tethys_cli/run tethys_cli/scaffold tethys_cli/schedulers tethys_cli/services diff --git a/docs/tethys_cli/run.rst b/docs/tethys_cli/run.rst new file mode 100644 index 000000000..81e03caa8 --- /dev/null +++ b/docs/tethys_cli/run.rst @@ -0,0 +1,80 @@ +.. _tethys_run_cmd: + +run command +*********** + +Run a single-file Tethys component app with zero configuration ("express mode"). Inspired by tools like ``shiny run`` and ``streamlit run``, this command lets you go from a single Python file to a running app in one step — no portal configuration, no database setup, no ``pip install``, and no login required. + +.. important:: + + This command only supports :ref:`Component Apps `. Classic template-based apps are not supported. + +Quick Start +=========== + +Create a file called :file:`app.py`: + +.. code-block:: python + + from tethys_sdk.components import ComponentBase + + + class App(ComponentBase): + name = "My Dashboard" + + + @App.page + def home(lib): + return lib.tethys.Display( + lib.tethys.Map() + ) + +Then run it: + +.. code-block:: bash + + tethys run + +The first run initializes an isolated environment for the app (a few seconds); then your default browser opens directly to the running app. Edits to :file:`app.py` are picked up automatically while the server is running. + +Note that the app class above only sets ``name`` — and even that is optional. In express mode, any required metadata that is not defined on the app class (``package``, ``name``, ``root_url``, ``index``) is derived automatically from the file name. The same file can later be dropped unchanged into the :file:`app.py` of a scaffolded component app project to install it in a full Tethys Portal (see :ref:`scaffold command ` and the :ref:`Component App Basics tutorial `). + +How It Works +============ + +``tethys run`` reuses the standard Tethys Portal runtime, configured down to serve a single app: + +* An isolated ``TETHYS_HOME`` is created for each app at :file:`~/.tethys/express/_/`, keyed by the absolute path of the app file. It contains a generated :file:`portal_config.yml` (single-app mode with ``ENABLE_OPEN_PORTAL``) and a SQLite database that is migrated automatically. Your normal portal configuration in ``TETHYS_HOME`` is not touched. +* The app file is loaded directly into the ``tethysapp`` namespace at server startup — it does not need to be installed as a package. +* The app is served at the site root with no login required (open portal mode). State is preserved between runs of the same file; use ``--clean`` to start fresh. + +Because the standard portal machinery is used unchanged, an app developed with ``tethys run`` behaves identically when installed in a full Tethys Portal. + +Arguments +========= + +.. argparse:: + :module: tethys_cli + :func: tethys_command_parser + :prog: tethys + :path: run + +Examples +======== + +.. code-block:: bash + + # Run app.py from the current directory + tethys run + + # Run a specific file on a specific port without opening a browser + tethys run my_dashboard.py -p 8080 --no-browser + + # Serve on all interfaces (e.g. to share on a local network) + tethys run --host 0.0.0.0 -p 8080 + + # Discard the app's saved state (database and generated config) and start fresh + tethys run --clean + + # Disable automatic restart on file changes + tethys run --no-reload diff --git a/docs/tethys_sdk/components.rst b/docs/tethys_sdk/components.rst index 2aa982c3a..38a9983ae 100644 --- a/docs/tethys_sdk/components.rst +++ b/docs/tethys_sdk/components.rst @@ -16,6 +16,10 @@ The ``app.py`` file is the heart of your Tethys Component App. It typically incl - An ``App`` class inheriting from ``ComponentBase`` (required) - The code for your app's Component-based Pages (optional, see note below) +.. tip:: + + A single ``app.py`` file is a complete, runnable app: the ``tethys run`` command serves it directly with zero portal configuration — ideal for prototyping, demos, and getting started. See :ref:`tethys_run_cmd`. + .. note:: As your app grows, you may want to organize your code into multiple files for better maintainability. For example, you can create a ``pages.py`` file to define your page functions and simply import your ``App`` class into them to access the page decorator. Or, you could even have a ``pages`` directory with multiple files, each containing a set of related page functions. Just make sure to import the ``App`` class from your ``app.py`` file in each of these files to use the page decorator. diff --git a/docs/whats_new.rst b/docs/whats_new.rst index 62124ec6b..771876bd5 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -11,6 +11,15 @@ Refer to this article for information about each new release of Tethys Platform. Release |version| ================= +Tethys Run (Express Mode) +------------------------- + +* The new ``tethys run`` command runs a single-file component app with zero configuration — no portal configuration, no database setup, no ``pip install``, and no login required. +* Inspired by tools like Shiny and Streamlit, it lowers the barrier to entry for building Tethys apps: write one Python file and run it with one command. +* Apps developed this way run on the standard portal machinery and can be installed in a full Tethys Portal without modification. + +See: :ref:`tethys_run_cmd` + New Recipes -----------