Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/tethys_cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions docs/tethys_cli/run.rst
Original file line number Diff line number Diff line change
@@ -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 <tethys_components>`. Classic template-based apps are not supported.

Comment on lines +8 to +11
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 <tethys_scaffold_cmd>` and the :ref:`Component App Basics tutorial <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/<package>_<hash>/`, 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
4 changes: 4 additions & 0 deletions docs/tethys_sdk/components.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions docs/whats_new.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----------

Expand Down
230 changes: 230 additions & 0 deletions tests/unit_tests/test_tethys_apps/test_base/test_express.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading