diff --git a/docs/source-app/glossary/command_lines/command_lines.rst b/docs/source-app/glossary/command_lines/command_lines.rst new file mode 100644 index 0000000000000..1ad4cdfeefab0 --- /dev/null +++ b/docs/source-app/glossary/command_lines/command_lines.rst @@ -0,0 +1,76 @@ +:orphan: + +############################ +Command-line Interface (CLI) +############################ + +**Audience:** Users looking to create a command line interface (CLI) for their application. + +---- + +************** +What is a CLI? +************** + +A Command-line Interface (CLI) is an user interface (UI) in a terminal to interact with a specific program. + +.. note:: + + The Lightning guideline to build CLI is `lightning ...` or ` ...`. + +As an example, Lightning provides a CLI to interact with your Lightning Apps and the `lightning.ai `_ platform as follows: + +.. code-block:: bash + + main + ├── create - Creates Lightning AI self-managed resources (clusters, etc…) + │ └── cluster - Creates a Lightning AI BYOC compute cluster with your cloud provider credentials. + ├── delete - Deletes Lightning AI self-managed resources (clusters, etc…) + │ └── cluster - Deletes a Lightning AI BYOC compute cluster and all associated cloud provider resources. + ├── fork - Forks an App. + ├── init - Initializes a Lightning App and/or Component. + │ ├── app + │ ├── component + │ ├── pl-app - Creates an App from your PyTorch Lightning source files. + │ └── react-ui - Creates a React UI to give a Lightning Component a React.js web UI + ├── install - Installs a Lightning App and/or Component. + │ ├── app + │ └── component + ├── list - Lists Lightning AI self-managed resources (clusters, etc…) + │ ├── apps - Lists your Lightning AI Apps. + │ └── clusters - Lists your Lightning AI BYOC compute clusters. + ├── login - Logs in to your lightning.ai account. + ├── logout - Logs out of your lightning.ai account. + ├── run - Runs a Lightning App locally or on the cloud. + │ └── app - Runs an App from a file. + ├── show - Shows given resource. + │ ├── cluster - Groups cluster commands inside show. + │ │ └── logs - Shows cluster logs. + │ └── logs - Shows cloud application logs. By default prints logs for all currently available Components. + ├── stop - Stops your App. + └── tree - Shows the command tree of your CLI. + +Learn more about `Command-line interfaces here `_. + +---- + +********** +Learn more +********** + +.. raw:: html + +
+
+ +.. displayitem:: + :header: Develop a Command Line Interface + :description: Learn how to develop a CLI for your App. + :col_css: col-md-6 + :button_link: ../../workflows/build_command_line_interface/index_content.html + :height: 150 + +.. raw:: html + +
+
diff --git a/docs/source-app/glossary/restful_api/restful_api.rst b/docs/source-app/glossary/restful_api/restful_api.rst new file mode 100644 index 0000000000000..a1128f2234558 --- /dev/null +++ b/docs/source-app/glossary/restful_api/restful_api.rst @@ -0,0 +1,53 @@ +:orphan: + +########### +RESTful API +########### + +**Audience:** Users looking to create an API in their App to allow users to activate functionalities from external sources. + +---- + +********************** +What is a RESTful API? +********************** + +A RESTful API is a set of external URL routes exposed by a server that enables clients to trigger some functionalities, such as getting or putting some data, uploading files, etc.. + +This provides great flexibility for users as they can easily discover functionalities made available by the App Builders. + +The Lightning App framework supports the four primary HTTP methods: `GET`, `POST`, `PUT`, `DELETE`. + +These methods are guidelines to organize your RESTful Services and help users understand your functionalities. + +* **`GET`:** Reads data from the server. +* **`POST`:** Creates new resources. +* **`PUT`:** Updates/replaces existing resources. +* **`DELETE`:** Deletes resources. + +Learn more about `HTTP Methods for RESTful Services here `_. + +The Lightning App framework uses the popular `FastAPI `_ and `Pydantic `_ frameworks under the hood. This means you can use all their features while building your App. + +---- + +********** +Learn more +********** + +.. raw:: html + +
+
+ +.. displayitem:: + :header: Develop a RESTful API + :description: Learn how to develop an API for your App. + :col_css: col-md-6 + :button_link: ../../workflows/build_rest_api/index_content.html + :height: 150 + +.. raw:: html + +
+
diff --git a/docs/source-app/index.rst b/docs/source-app/index.rst index 6c701ffd574d9..af0e7eb350827 100644 --- a/docs/source-app/index.rst +++ b/docs/source-app/index.rst @@ -220,8 +220,10 @@ Keep Learning Add a web user interface (UI) Add a web link Arrange app tabs + Develop a Command Line Interface (CLI) Develop a Lightning App Develop a Lightning Component + Develop a REST API Cache Work run calls Customize your cloud compute Extend an existing app @@ -264,10 +266,12 @@ Keep Learning App Components Tree Build Configuration + Command Line Interface (CLI) DAG Event Loop Environment Variables Frontend + REST API Sharing Components Scheduling Storage diff --git a/docs/source-app/workflows/build_command_line_interface/app.py b/docs/source-app/workflows/build_command_line_interface/app.py new file mode 100644 index 0000000000000..f6a398096b96c --- /dev/null +++ b/docs/source-app/workflows/build_command_line_interface/app.py @@ -0,0 +1,36 @@ +from commands.notebook.run import RunNotebook, RunNotebookConfig +from lit_jupyter import JupyterLab + +import lightning as L +from lightning.app.structures import Dict + + +class Flow(L.LightningFlow): + + def __init__(self): + super().__init__() + self.notebooks = Dict() + + # 1. Annotates the handler input with the Notebook config. + def run_notebook(self, config: RunNotebookConfig): + if config.name in self.notebooks: + return f"The Notebook {config.name} already exists." + else: + # 2. Dynamically creates the Notebook if it doesn't exist and runs it. + self.notebooks[config.name] = JupyterLab( + cloud_compute=L.CloudCompute(config.cloud_compute) + ) + self.notebooks[config.name].run() + return f"The Notebook {config.name} was created." + + def configure_commands(self): + # 3. Returns a list of dictionaries with the format: + # {"command_name": CustomClientCommand(method=self.custom_server_handler)} + return [{"run notebook": RunNotebook(method=self.run_notebook)}] + + def configure_layout(self): + # 4. Dynamically displays the Notebooks in the Lightning App View. + return [{"name": n, "content": w} for n, w in self.notebooks.items()] + + +app = L.LightningApp(Flow()) diff --git a/docs/source-app/workflows/build_command_line_interface/cli.rst b/docs/source-app/workflows/build_command_line_interface/cli.rst new file mode 100644 index 0000000000000..4608e5675ba92 --- /dev/null +++ b/docs/source-app/workflows/build_command_line_interface/cli.rst @@ -0,0 +1,144 @@ +:orphan: + +########################################### +1. Develop a CLI with server side code only +########################################### + +We are going to learn how to create a simple command-line interface. + +Lightning provides a flexible way to create complex CLI without much effort. + +---- + +************************* +1. Implement a simple CLI +************************* + +To create your first CLI, you need to override the :class:`~lightning_app.core.flow.LightningFlow.configure_commands` hook and return a list of dictionaries where the keys are the commands and the values are the server side handlers. + +First, create a file ``app.py`` and copy-paste the following code in to the file: + +.. literalinclude:: example_command.py + +---- + +************** +2. Run the App +************** + +Execute the following command in a terminal: + +.. code-block:: + + lightning run app app.py + +The following appears the terminal: + +.. code-block:: + + Your Lightning App is starting. This won't take long. + INFO: Your app has started. View it in your browser: http://127.0.0.1:7501/view + [] + +---- + +*************************** +3. Connect to a running App +*************************** + +In another terminal, connect to the running App. +When you connect to an App, the Lightning CLI is replaced by the App CLI. To exit the App CLI, you need to run ``lightning disconnect``. + +.. code-block:: + + lightning connect localhost + +To see a list of available commands: + +.. code-block:: + + lightning --help + You are connected to the cloud Lightning App: localhost. + Usage: lightning [OPTIONS] COMMAND [ARGS]... + + --help Show this message and exit. + + Lightning App Commands + add Description + +To find the arguments of the commands: + +.. code-block:: + + lightning add --help + You are connected to the cloud Lightning App: localhost. + Usage: lightning add [ARGS]... + + Options + name: Add description + +---- + +******************** +4. Execute a command +******************** + +Trigger the command line exposed by your App: + +.. code-block:: + + lightning add --name=my_name + WARNING: Lightning Command Line Interface is an experimental feature and unannounced changes are likely. + +In your first terminal, **Received name: my_name** and **["my_name"]** are printed. + +.. code-block:: + + Your Lightning App is starting. This won't take long. + INFO: Your app has started. View it in your browser: http://127.0.0.1:7501/view + [] + Received name: my_name + ["my_name] + +---- + +************************** +5. Disconnect from the App +************************** + +To exit the App CLI, you need to run ``lightning disconnect``. + +.. code-block:: + + lightning disconnect + You are disconnected from the local Lightning App. + +---- + +********** +Learn more +********** + +.. raw:: html + +
+
+ +.. displayitem:: + :header: 2. Implement a CLI with client side code execution + :description: Learn how to develop a complex API for your application + :col_css: col-md-6 + :button_link: cli_client.html + :height: 150 + +.. displayitem:: + :header: Develop a RESTful API + :description: Learn how to develop an API for your application. + :col_css: col-md-6 + :button_link: ../build_rest_api/index.html + :height: 150 + +.. raw:: html + +
+
diff --git a/docs/source-app/workflows/build_command_line_interface/cli_client.rst b/docs/source-app/workflows/build_command_line_interface/cli_client.rst new file mode 100644 index 0000000000000..96a2b41195492 --- /dev/null +++ b/docs/source-app/workflows/build_command_line_interface/cli_client.rst @@ -0,0 +1,176 @@ +:orphan: + +###################################################### +2. Develop a CLI with server and client code execution +###################################################### + +We've learned how to create a simple command-line interface. But in real-world use-cases, an App Builder wants to provide more complex functionalities where trusted code is executed on the client side. + +Lightning provides a flexible way to create complex CLI without much effort. + +In this example, we’ll create a CLI to dynamically run Notebooks: + + +---- + +************************** +1. Implement a complex CLI +************************** + +First of all, lets' create the following file structure: + +.. code-block:: python + + app_folder/ + commands/ + notebook/ + run.py + app.py + +We'll use the `Jupyter-Component `_. Follow the installation steps on the repo to install the Component. + +Add the following code to ``commands/notebook/run.py``: + +.. literalinclude:: commands/notebook/run.py + +Add the following code to ``app.py``: + +.. literalinclude:: app.py + +---- + +********************************************** +2. Run the App and check the API documentation +********************************************** + +In a terminal, run the following command and open ``http://127.0.0.1:7501/docs`` in a browser. + +.. code-block:: python + + lightning run app app.py + Your Lightning App is starting. This won't take long. + INFO: Your app has started. View it in your browser: http://127.0.0.1:7501/view + +---- + +*************************** +3. Connect to a running App +*************************** + +In another terminal, connect to the running App. +When you connect to an App, the Lightning CLI is replaced by the App CLI. To exit the App CLI, you need to run ``lightning disconnect``. + +.. code-block:: + + lightning connect localhost + + Storing `run_notebook` under /Users/thomas/.lightning/lightning_connection/commands/run_notebook.py + You can review all the downloaded commands under /Users/thomas/.lightning/lightning_connection/commands folder. + You are connected to the local Lightning App. + +To see a list of available commands: + +.. code-block:: + + lightning --help + + You are connected to the cloud Lightning App: localhost. + Usage: lightning [OPTIONS] COMMAND [ARGS]... + + --help Show this message and exit. + + Lightning App Commands + run notebook Description + + +To find the arguments of the commands: + +.. code-block:: + + lightning run notebook --help + + You are connected to the cloud Lightning App: localhost. + usage: notebook [-h] [--name NAME] [--cloud_compute CLOUD_COMPUTE] + + Run Notebook Parser + + optional arguments: + -h, --help show this help message and exit + --name NAME + --cloud_compute CLOUD_COMPUTE + +---- + +******************** +4. Execute a command +******************** + +And then you can trigger the command-line exposed by your App. + +Run the first Notebook with the following command: + +.. code-block:: python + + lightning run notebook --name="my_notebook" + WARNING: Lightning Command Line Interface is an experimental feature and unannounced changes are likely. + The notebook my_notebook was created. + +And run a second notebook. + +.. code-block:: python + + lightning run notebook --name="my_notebook_2" + WARNING: Lightning Command Line Interface is an experimental feature and unannounced changes are likely. + The notebook my_notebook_2 was created. + +Here is a recording of the Lightning App: + +.. raw:: html + +
+ +
+
+ +************************** +5. Disconnect from the App +************************** + +To exit the App CLI, you need to run **lightning disconnect**. + +.. code-block:: + + lightning disconnect + You are disconnected from the local Lightning App. + +---- + +********** +Learn more +********** + +.. raw:: html + +
+
+ +.. displayitem:: + :header: 1. Develop a CLI with server side code only + :description: Learn how to develop a simple CLI for your App. + :col_css: col-md-6 + :button_link: cli.html + :height: 150 + +.. displayitem:: + :header: Develop a RESTful API + :description: Learn how to develop an API for your App. + :col_css: col-md-6 + :button_link: ../build_rest_api/index.html + :height: 150 + +.. raw:: html + +
+
diff --git a/docs/source-app/workflows/build_command_line_interface/commands/__init__.py b/docs/source-app/workflows/build_command_line_interface/commands/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/docs/source-app/workflows/build_command_line_interface/commands/notebook/__init__.py b/docs/source-app/workflows/build_command_line_interface/commands/notebook/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/docs/source-app/workflows/build_command_line_interface/commands/notebook/run.py b/docs/source-app/workflows/build_command_line_interface/commands/notebook/run.py new file mode 100644 index 0000000000000..4e3bc67d9e538 --- /dev/null +++ b/docs/source-app/workflows/build_command_line_interface/commands/notebook/run.py @@ -0,0 +1,32 @@ +from argparse import ArgumentParser +from uuid import uuid4 + +from pydantic import BaseModel + +from lightning.app.utilities.commands import ClientCommand + + +class RunNotebookConfig(BaseModel): + name: str + cloud_compute: str + + +class RunNotebook(ClientCommand): + + def run(self): + # 1. Define your own argument parser. You can use argparse, click, etc... + parser = ArgumentParser(description='Run Notebook Parser') + parser.add_argument("--name", type=str, default=None) + parser.add_argument("--cloud_compute", type=str, default="cpu") + hparams = parser.parse_args() + + # 2. Invoke the server side handler by sending a payload. + response = self.invoke_handler( + config=RunNotebookConfig( + name=hparams.name or str(uuid4()), + cloud_compute=hparams.cloud_compute, + ), + ) + + # 3. Print the server response. + print(response) diff --git a/docs/source-app/workflows/build_command_line_interface/example_command.py b/docs/source-app/workflows/build_command_line_interface/example_command.py new file mode 100644 index 0000000000000..3c013548af0a3 --- /dev/null +++ b/docs/source-app/workflows/build_command_line_interface/example_command.py @@ -0,0 +1,24 @@ +from lightning import LightningApp, LightningFlow + + +class Flow(LightningFlow): + def __init__(self): + super().__init__() + self.names = [] + + def run(self): + print(self.names) + + def add_name(self, name: str): + print(f"Received name: {name}") + self.names.append(name) + + def configure_commands(self): + # This can be invoked with `lightning add --name=my_name` + commands = [ + {"add": self.add_name}, + ] + return commands + + +app = LightningApp(Flow()) diff --git a/docs/source-app/workflows/build_command_line_interface/index.rst b/docs/source-app/workflows/build_command_line_interface/index.rst new file mode 100644 index 0000000000000..1f1b1b16163fb --- /dev/null +++ b/docs/source-app/workflows/build_command_line_interface/index.rst @@ -0,0 +1,55 @@ +############################ +Command-line Interface (CLI) +############################ + +**Audience:** Users looking to create a command line interface (CLI) for their application. + +---- + +************** +What is a CLI? +************** + +A Command-line Interface (CLI) is an user interface (UI) in a terminal to interact with a specific program. + +.. note:: + + The Lightning guideline to build CLI is `lightning ...` or ` ...`. + +As an example, Lightning provides a CLI to interact with your Lightning Apps and the `lightning.ai `_ platform as follows: + +.. code-block:: bash + + main + ├── create - Creates Lightning AI self-managed resources (clusters, etc…) + │ └── cluster - Creates a Lightning AI BYOC compute cluster with your cloud provider credentials. + ├── delete - Deletes Lightning AI self-managed resources (clusters, etc…) + │ └── cluster - Deletes a Lightning AI BYOC compute cluster and all associated cloud provider resources. + ├── fork - Forks an App. + ├── init - Initializes a Lightning App and/or Component. + │ ├── app + │ ├── component + │ ├── pl-app - Creates an App from your PyTorch Lightning source files. + │ └── react-ui - Creates a React UI to give a Lightning Component a React.js web UI + ├── install - Installs a Lightning App and/or Component. + │ ├── app + │ └── component + ├── list - Lists Lightning AI self-managed resources (clusters, etc…) + │ ├── apps - Lists your Lightning AI Apps. + │ └── clusters - Lists your Lightning AI BYOC compute clusters. + ├── login - Logs in to your lightning.ai account. + ├── logout - Logs out of your lightning.ai account. + ├── run - Runs a Lightning App locally or on the cloud. + │ └── app - Runs an App from a file. + ├── show - Shows given resource. + │ ├── cluster - Groups cluster commands inside show. + │ │ └── logs - Shows cluster logs. + │ └── logs - Shows cloud application logs. By default prints logs for all currently available Components. + ├── stop - Stops your App. + └── tree - Shows the command tree of your CLI. + +Learn more about `Command-line interfaces here `_. + +---- + +.. include:: index_content.rst diff --git a/docs/source-app/workflows/build_command_line_interface/index_content.rst b/docs/source-app/workflows/build_command_line_interface/index_content.rst new file mode 100644 index 0000000000000..ced369dbfd815 --- /dev/null +++ b/docs/source-app/workflows/build_command_line_interface/index_content.rst @@ -0,0 +1,51 @@ +************************************** +Develop a command line interface (CLI) +************************************** + +.. raw:: html + +
+
+ +.. displayitem:: + :header: 1. Develop a CLI with server side code only + :description: Learn how to develop a simple CLI for your application + :col_css: col-md-6 + :button_link: cli.html + :height: 150 + +.. displayitem:: + :header: 2. Develop a CLI with server and client code execution + :description: Learn how to develop a complex CLI for your application + :col_css: col-md-6 + :button_link: cli_client.html + :height: 150 + +.. raw:: html + +
+
+ + +---- + +********** +Learn more +********** + +.. raw:: html + +
+
+ +.. displayitem:: + :header: Develop a RESTful API + :description: Learn how to develop an API for your application. + :col_css: col-md-6 + :button_link: ../build_rest_api/index.html + :height: 150 + +.. raw:: html + +
+
diff --git a/docs/source-app/workflows/build_command_line_interface/post_example.py b/docs/source-app/workflows/build_command_line_interface/post_example.py new file mode 100644 index 0000000000000..c7f87f1cffdf7 --- /dev/null +++ b/docs/source-app/workflows/build_command_line_interface/post_example.py @@ -0,0 +1,27 @@ +import lightning as L +from lightning.app.api import Post + + +class Flow(L.LightningFlow): + + # 1. Define the state + def __init__(self): + super().__init__() + self.names = [] + + # 2. Optional, but used to validate names + def run(self): + print(self.names) + + # 3. Method executed when a request is received. + def handle_post(self, name: str): + self.names.append(name) + return f'The name {name} was registered' + + # 4. Defines this Component's Restful API. You can have several routes. + def configure_api(self): + # Your own defined route and handler + return [Post(route="/name", method=self.handle_post)] + + +app = L.LightningApp(Flow()) diff --git a/docs/source-app/workflows/build_rest_api/add_api.rst b/docs/source-app/workflows/build_rest_api/add_api.rst new file mode 100644 index 0000000000000..9538c7792f8d0 --- /dev/null +++ b/docs/source-app/workflows/build_rest_api/add_api.rst @@ -0,0 +1,102 @@ +:orphan: + +############################ +Add an API Route to your App +############################ + +In order to add a new route, you need to override the :class:`~lightning_app.core.flow.LightningFlow.configure_api` hook and return a list of :class:`~lightning_app.api.:class:`~lightning_app.api.http_methods.HttpMethod` such as :class:`~lightning_app.api.:class:`~lightning_app.api.http_methods.Get`, :class:`~lightning_app.api.:class:`~lightning_app.api.http_methods.Post`, :class:`~lightning_app.api.:class:`~lightning_app.api.http_methods.Put`, :class:`~lightning_app.api.:class:`~lightning_app.api.http_methods.Delete`. + +---- + +********************** +1. Create a simple App +********************** + +We're going to create a single route ``/name`` that takes a string input ``name`` and stores the value within the ``names`` attribute of the flow state. + +Create a file called ``app.py`` and copy-paste the following code in to the file: + +.. literalinclude:: post_example.py + +---- + +************** +2. Run the App +************** + +Execute the following command in a terminal: + +.. code-block:: + +lightning run app app.py + +The following appears: + +.. code-block:: + + Your Lightning App is starting. This won't take long. + INFO: Your app has started. View it in your browser: http://127.0.0.1:7501/view + +---- + +**************** +3. Check the API +**************** + +The Lightning App framework automatically generates API documentation from your App using `Swagger UI `_. + +You can access it by accessing the following URL: ``http://127.0.0.1:7501/docs`` in your browser and validate your API with the route ``/name`` directly from the documentation page as shown below. + +.. raw:: html + + + +Alternatively, you can invoke the route directly from a second terminal using `curl `_. + +.. code-block:: + + curl -X 'POST' \ + 'http://127.0.0.1:7501/name?name=my_name' \ + -H 'accept: application/json' \ + -d '' + + "The name my_name was registered" + +And you can see the following in your first terminal running your App. + +.. code-block:: + + Your Lightning App is starting. This won't take long. + INFO: Your app has started. View it in your browser: http://127.0.0.1:7501/view + [] + ["my_name"] + +************************************** +Develop a command line interface (CLI) +************************************** + +.. raw:: html + +
+
+ +.. displayitem:: + :header: Add Requests Validation + :description: Learn how to use pydantic with your API. + :col_css: col-md-6 + :button_link: request_validation.html + :height: 150 + +.. displayitem:: + :header: Develop a Command Line Interface (CLI) + :description: Learn how to develop an CLI for your App. + :col_css: col-md-6 + :button_link: ../build_command_line_interface/index.html + :height: 150 + +.. raw:: html + +
+
diff --git a/docs/source-app/workflows/build_rest_api/index.rst b/docs/source-app/workflows/build_rest_api/index.rst new file mode 100644 index 0000000000000..269b810ec6442 --- /dev/null +++ b/docs/source-app/workflows/build_rest_api/index.rst @@ -0,0 +1,32 @@ +########### +RESTful API +########### + +**Audience:** Users looking to create an API in their App to allow users to activate functionalities from external sources. + +---- + +********************** +What is a RESTful API? +********************** + +A RESTful API is a set of external URL routes exposed by a server that enables clients to trigger some functionalities, such as getting or putting some data, uploading files, etc.. + +This provides great flexibility for users as they can easily discover functionalities made available by the App Builders. + +The Lightning App framework supports the four primary HTTP methods: `GET`, `POST`, `PUT`, `DELETE`. + +These methods are guidelines to organize your RESTful Services and help users understand your functionalities. + +* **`GET`:** Reads data from the server. +* **`POST`:** Creates new resources. +* **`PUT`:** Updates/replaces existing resources. +* **`DELETE`:** Deletes resources. + +Learn more about `HTTP Methods for RESTful Services here `_. + +The Lightning App framework uses the popular `FastAPI `_ and `Pydantic `_ frameworks under the hood. This means you can use all their features while building your App. + +---- + +.. include:: index_content.rst diff --git a/docs/source-app/workflows/build_rest_api/index_content.rst b/docs/source-app/workflows/build_rest_api/index_content.rst new file mode 100644 index 0000000000000..9f77225f24f59 --- /dev/null +++ b/docs/source-app/workflows/build_rest_api/index_content.rst @@ -0,0 +1,50 @@ +************** +Develop an API +************** + +.. raw:: html + +
+
+ +.. displayitem:: + :header: Add an API Route to your App + :description: Learn how to develop a simple API for your App. + :col_css: col-md-6 + :button_link: add_api.html + :height: 150 + +.. displayitem:: + :header: Add Requests Validation + :description: Learn how to use pydantic with your API. + :col_css: col-md-6 + :button_link: cli_client.html + :height: 150 + +.. raw:: html + +
+
+ +---- + +********** +Learn more +********** + +.. raw:: html + +
+
+ +.. displayitem:: + :header: Develop a Command-line Interface + :description: Learn how to develop an CLI for your App. + :col_css: col-md-6 + :button_link: ../build_command_line_interface/index.html + :height: 150 + +.. raw:: html + +
+
diff --git a/docs/source-app/workflows/build_rest_api/models.py b/docs/source-app/workflows/build_rest_api/models.py new file mode 100644 index 0000000000000..7ebb3ac8c8c17 --- /dev/null +++ b/docs/source-app/workflows/build_rest_api/models.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +# 1. Subclass the BaseModel and defines your payload format. +class NamePostConfig(BaseModel): + name: str diff --git a/docs/source-app/workflows/build_rest_api/post_example.py b/docs/source-app/workflows/build_rest_api/post_example.py new file mode 100644 index 0000000000000..4a306f176e4b0 --- /dev/null +++ b/docs/source-app/workflows/build_rest_api/post_example.py @@ -0,0 +1,26 @@ +import lightning as L +from lightning.app.api import Post + + +class Flow(L.LightningFlow): + + # 1. Define the state + def __init__(self): + super().__init__() + self.names = [] + + # 2. Optional, but used to validate names + def run(self): + print(self.names) + + # 3. Method executed when a request is received. + def handle_post(self, name: str): + self.names.append(name) + return f'The name {name} was registered' + + # 4. Defines this Component's Restful API. You can have several routes. + def configure_api(self): + return [Post(route="/name", method=self.handle_post)] + + +app = L.LightningApp(Flow()) diff --git a/docs/source-app/workflows/build_rest_api/post_example_pydantic.py b/docs/source-app/workflows/build_rest_api/post_example_pydantic.py new file mode 100644 index 0000000000000..e3c16ca35de48 --- /dev/null +++ b/docs/source-app/workflows/build_rest_api/post_example_pydantic.py @@ -0,0 +1,33 @@ +from models import NamePostConfig # 2. Import your custom model. + +import lightning as L +from lightning.app.api import Post + + +class Flow(L.LightningFlow): + + # 1. Define the state + def __init__(self): + super().__init__() + self.names = [] + + # 2. Optional, but used to validate names + def run(self): + print(self.names) + + # 3. Annotate your input with your custom pydantic model. + def handle_post(self, config: NamePostConfig): + self.names.append(config.name) + return f'The name {config} was registered' + + # 4. Defines this Component's Restful API. You can have several routes. + def configure_api(self): + return [ + Post( + route="/name", + method=self.handle_post, + ) + ] + + +app = L.LightningApp(Flow()) diff --git a/docs/source-app/workflows/build_rest_api/request_validation.rst b/docs/source-app/workflows/build_rest_api/request_validation.rst new file mode 100644 index 0000000000000..a34b2dd04910d --- /dev/null +++ b/docs/source-app/workflows/build_rest_api/request_validation.rst @@ -0,0 +1,69 @@ +:orphan: + +*********************** +Add Requests Validation +*********************** + +The Lightning App framework uses the popular `FastAPI `_ and `Pydantic `_ frameworks under the hood. This means you can use all their features while building your App. + +pydantic enables fast data validation and settings management using Python type annotations and FastAPI is a modern, fast (high-performance), web framework for building APIs. + +You can easily use pydantic by defining your own payload format. + +.. literalinclude:: models.py + +Then, type your handler input with your custom model. + +.. literalinclude:: post_example_pydantic.py + +After running the updated App, the App documentation ``/name`` has changed and takes JSON with ``{"name": ...}`` as input. + +.. figure:: https://pl-flash-data.s3.amazonaws.com/assets_lightning/rest_post_pydantic.png + :alt: Rest API with pydantic + :width: 100 % + +You can invoke the RESTful API route ``/name`` with the following command: + +.. code-block:: bash + + curl -X 'POST' \ + 'http://127.0.0.1:7501/name' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "my_name" + }' + +.. note:: + + Using curl, you can pass a JSON payload using the ``-d`` argument. + +---- + +********** +Learn more +********** + +.. raw:: html + +
+
+ +.. displayitem:: + :header: Add an API Route to your App + :description: Learn how to develop a simple API for your App. + :col_css: col-md-6 + :button_link: add_api.html + :height: 150 + +.. displayitem:: + :header: Develop a Command Line Interface (CLI) + :description: Learn how to develop an CLI for your App. + :col_css: col-md-6 + :button_link: ../build_command_line_interface/index.html + :height: 150 + +.. raw:: html + +
+
diff --git a/src/lightning_app/cli/commands/__init__.py b/src/lightning_app/cli/commands/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/lightning_app/cli/commands/app_commands.py b/src/lightning_app/cli/commands/app_commands.py new file mode 100644 index 0000000000000..0b08538e76ba6 --- /dev/null +++ b/src/lightning_app/cli/commands/app_commands.py @@ -0,0 +1,93 @@ +import os +import sys +from typing import Dict, Optional + +import requests + +from lightning_app.cli.commands.connection import _resolve_command_path +from lightning_app.utilities.cli_helpers import _retrieve_application_url_and_available_commands +from lightning_app.utilities.commands.base import _download_command +from lightning_app.utilities.enum import OpenAPITags + + +def _run_app_command(app_name: str, app_id: Optional[str]): + """Execute a function in a running App from its name.""" + # 1: Collect the url and comments from the running application + url, api_commands, _ = _retrieve_application_url_and_available_commands(app_id) + if url is None or api_commands is None: + raise Exception("We couldn't find any matching running App.") + + if not api_commands: + raise Exception("This application doesn't expose any commands yet.") + + full_command = "_".join(sys.argv) + + has_found = False + for command in list(api_commands): + if command in full_command: + has_found = True + break + + if not has_found: + raise Exception(f"The provided command isn't available in {list(api_commands)}") + + # 2: Send the command from the user + metadata = api_commands[command] + + # 3: Execute the command + if metadata["tag"] == OpenAPITags.APP_COMMAND: + _handle_command_without_client(command, metadata, url) + else: + _handle_command_with_client(command, metadata, app_name, app_id, url) + + if sys.argv[-1] != "--help": + print("Your command execution was successful.") + + +def _handle_command_without_client(command: str, metadata: Dict, url: str) -> None: + supported_params = list(metadata["parameters"]) + if "--help" == sys.argv[-1]: + print(f"Usage: lightning {command} [ARGS]...") + print(" ") + print("Options") + for param in supported_params: + print(f" {param}: Add description") + return + + provided_params = [param.replace("--", "") for param in sys.argv[1 + len(command.split("_")) :]] + + # TODO: Add support for more argument types. + if any("=" not in param for param in provided_params): + raise Exception("Please, use --x=y syntax when providing the command arguments.") + + if any(param.split("=")[0] not in supported_params for param in provided_params): + raise Exception(f"Some arguments need to be provided. The keys are {supported_params}.") + + # TODO: Encode the parameters and validate their type. + query_parameters = "&".join(provided_params) + resp = requests.post(url + f"/command/{command}?{query_parameters}") + assert resp.status_code == 200, resp.json() + + +def _handle_command_with_client(command: str, metadata: Dict, app_name: str, app_id: Optional[str], url: str): + debug_mode = bool(int(os.getenv("DEBUG", "0"))) + + if app_name == "localhost": + target_file = metadata["cls_path"] + else: + target_file = _resolve_command_path(command) if debug_mode else _resolve_command_path(command) + + if debug_mode: + print(target_file) + + client_command = _download_command( + command, + metadata["cls_path"], + metadata["cls_name"], + app_id, + debug_mode=debug_mode, + target_file=target_file if debug_mode else _resolve_command_path(command), + ) + client_command._setup(command_name=command, app_url=url) + sys.argv = sys.argv[len(command.split("_")) :] + client_command.run() diff --git a/src/lightning_app/cli/commands/connection.py b/src/lightning_app/cli/commands/connection.py new file mode 100644 index 0000000000000..e4288219d3095 --- /dev/null +++ b/src/lightning_app/cli/commands/connection.py @@ -0,0 +1,197 @@ +import os +import shutil +from typing import List, Optional, Tuple + +import click + +from lightning_app.utilities.cli_helpers import _retrieve_application_url_and_available_commands +from lightning_app.utilities.cloud import _get_project +from lightning_app.utilities.network import LightningClient + + +@click.argument("app_name_or_id", required=True) +@click.option("-y", "--yes", required=False, is_flag=True, help="Whether to download the commands automatically.") +def connect(app_name_or_id: str, yes: bool = False): + """Connect to a Lightning App.""" + from lightning_app.utilities.commands.base import _download_command + + home = os.path.expanduser("~") + lightning_folder = os.path.join(home, ".lightning", "lightning_connection") + + if not os.path.exists(lightning_folder): + os.makedirs(lightning_folder) + + connected_file = os.path.join(lightning_folder, "connect.txt") + + if os.path.exists(connected_file): + with open(connected_file) as f: + result = f.readlines()[0].replace("\n", "") + + if result == app_name_or_id: + if app_name_or_id == "localhost": + click.echo("You are connected to the local Lightning App.") + else: + click.echo(f"You are already connected to the cloud Lightning App: {app_name_or_id}.") + else: + click.echo("You are already connected to a Lightning App. Please, use `lightning disconnect`.") + + elif app_name_or_id.startswith("localhost"): + + if app_name_or_id != "localhost": + raise Exception("You need to pass localhost to connect to the local Lightning App.") + + _, api_commands, __cached__ = _retrieve_application_url_and_available_commands(None) + + if api_commands is None: + raise Exception(f"The commands weren't found. Is your app {app_name_or_id} running ?") + + commands_folder = os.path.join(lightning_folder, "commands") + if not os.path.exists(commands_folder): + os.makedirs(commands_folder) + + for command_name, metadata in api_commands.items(): + if "cls_path" in metadata: + target_file = os.path.join(commands_folder, f"{command_name.replace(' ','_')}.py") + _download_command( + command_name, + metadata["cls_path"], + metadata["cls_name"], + None, + target_file=target_file, + ) + click.echo(f"Storing `{command_name}` under {target_file}") + click.echo(f"You can review all the downloaded commands under {commands_folder} folder.") + else: + with open(os.path.join(commands_folder, f"{command_name}.txt"), "w") as f: + f.write(command_name) + + with open(connected_file, "w") as f: + f.write(app_name_or_id + "\n") + + click.echo("You are connected to the local Lightning App.") + else: + _, api_commands, lightningapp_id = _retrieve_application_url_and_available_commands(app_name_or_id) + + if not api_commands: + client = LightningClient() + project = _get_project(client) + lightningapps = client.lightningapp_instance_service_list_lightningapp_instances(project.project_id) + click.echo( + "We didn't find a matching App. Here are the available Apps that could be " + f"connected to {[app.name for app in lightningapps.lightningapps]}." + ) + return + + assert lightningapp_id + + if not yes: + yes = click.confirm( + f"The Lightning App `{app_name_or_id}` provides a command-line (CLI). " + "Do you want to proceed and install its CLI ?" + ) + click.echo(" ") + + if yes: + commands_folder = os.path.join(lightning_folder, "commands") + if not os.path.exists(commands_folder): + os.makedirs(commands_folder) + + for command_name, metadata in api_commands.items(): + if "cls_path" in metadata: + target_file = os.path.join(commands_folder, f"{command_name}.py") + _download_command( + command_name, + metadata["cls_path"], + metadata["cls_name"], + lightningapp_id, + target_file=target_file, + ) + click.echo(f"Storing `{command_name}` under {target_file}") + click.echo(f"You can review all the downloaded commands under {commands_folder} folder.") + else: + with open(os.path.join(commands_folder, f"{command_name}.txt"), "w") as f: + f.write(command_name) + + click.echo(" ") + click.echo("The client interface has been successfully installed. ") + click.echo("You can now run the following commands:") + for command in api_commands: + click.echo(f" lightning {command}") + + with open(connected_file, "w") as f: + f.write(app_name_or_id + "\n") + f.write(lightningapp_id + "\n") + click.echo(" ") + click.echo(f"You are connected to the cloud Lightning App: {app_name_or_id}.") + + +def disconnect(logout: bool = False): + """Disconnect from an App.""" + home = os.path.expanduser("~") + lightning_folder = os.path.join(home, ".lightning", "lightning_connection") + connected_file = os.path.join(lightning_folder, "connect.txt") + if os.path.exists(connected_file): + with open(connected_file) as f: + result = f.readlines()[0].replace("\n", "") + + os.remove(connected_file) + commands_folder = os.path.join(lightning_folder, "commands") + if os.path.exists(commands_folder): + shutil.rmtree(commands_folder) + + if result == "localhost": + click.echo("You are disconnected from the local Lightning App.") + else: + click.echo(f"You are disconnected from the cloud Lightning App: {result}.") + else: + if not logout: + click.echo( + "You aren't connected to any Lightning App. " + "Please use `lightning connect app_name_or_id` to connect to one." + ) + + +def _retrieve_connection_to_an_app() -> Tuple[Optional[str], Optional[str]]: + home = os.path.expanduser("~") + lightning_folder = os.path.join(home, ".lightning", "lightning_connection") + connected_file = os.path.join(lightning_folder, "connect.txt") + + if os.path.exists(connected_file): + with open(connected_file) as f: + lines = [line.replace("\n", "") for line in f.readlines()] + if len(lines) == 2: + return lines[0], lines[1] + return lines[0], None + return None, None + + +def _get_commands_folder() -> str: + home = os.path.expanduser("~") + lightning_folder = os.path.join(home, ".lightning", "lightning_connection") + return os.path.join(lightning_folder, "commands") + + +def _resolve_command_path(command: str) -> str: + return os.path.join(_get_commands_folder(), f"{command}.py") + + +def _list_app_commands() -> List[str]: + command_names = sorted( + n.replace(".py", "").replace(".txt", "").replace("_", " ") + for n in os.listdir(_get_commands_folder()) + if n != "__pycache__" + ) + if not command_names: + click.echo("The current Lightning App doesn't have commands.") + return [] + + click.echo("Usage: lightning [OPTIONS] COMMAND [ARGS]...") + click.echo("") + click.echo(" --help Show this message and exit.") + click.echo("") + click.echo("Lightning App Commands") + max_length = max(len(n) for n in command_names) + for command_name in command_names: + padding = (max_length + 1 - len(command_name)) * " " + click.echo(f" {command_name}{padding}Description") + return command_names diff --git a/src/lightning_app/cli/lightning_cli.py b/src/lightning_app/cli/lightning_cli.py index d590cbc667f8a..83a8efcb5334a 100644 --- a/src/lightning_app/cli/lightning_cli.py +++ b/src/lightning_app/cli/lightning_cli.py @@ -1,13 +1,11 @@ import logging import os import sys -from argparse import ArgumentParser from pathlib import Path from typing import List, Tuple, Union import arrow import click -import requests import rich from requests.exceptions import ConnectionError from rich.color import ANSI_COLOR_NAMES @@ -15,6 +13,13 @@ from lightning_app import __version__ as ver from lightning_app.cli import cmd_init, cmd_install, cmd_pl_init, cmd_react_ui_init from lightning_app.cli.cmd_clusters import AWSClusterManager +from lightning_app.cli.commands.app_commands import _run_app_command +from lightning_app.cli.commands.connection import ( + _list_app_commands, + _retrieve_connection_to_an_app, + connect, + disconnect, +) from lightning_app.cli.lightning_cli_create import create from lightning_app.cli.lightning_cli_delete import delete from lightning_app.cli.lightning_cli_list import get_list @@ -22,14 +27,9 @@ from lightning_app.runners.runtime import dispatch from lightning_app.runners.runtime_type import RuntimeType from lightning_app.utilities.app_logs import _app_logs_reader -from lightning_app.utilities.cli_helpers import ( - _arrow_time_callback, - _format_input_env_variables, - _retrieve_application_url_and_available_commands, -) +from lightning_app.utilities.cli_helpers import _arrow_time_callback, _format_input_env_variables from lightning_app.utilities.cloud import _get_project from lightning_app.utilities.cluster_logs import _cluster_logs_reader -from lightning_app.utilities.enum import OpenAPITags from lightning_app.utilities.install_components import register_all_external_components from lightning_app.utilities.login import Auth from lightning_app.utilities.network import LightningClient @@ -46,12 +46,29 @@ def get_app_url(runtime_type: RuntimeType, *args) -> str: def main(): - if len(sys.argv) == 1: - _main() - elif sys.argv[1] in _main.commands.keys() or sys.argv[1] == "--help": + # 1: Handle connection to a Lightning App. + if sys.argv[1] in ("connect", "disconnect"): _main() else: - app_command() + # 2: Collect the connection a Lightning App. + app_name, app_id = _retrieve_connection_to_an_app() + if app_name: + # 3: Handle development use case. + is_local_app = app_name == "localhost" + if is_local_app and sys.argv[1:3] == ["run", "app"]: + _main() + else: + if is_local_app: + click.echo("You are connected to the local Lightning App.") + else: + click.echo(f"You are connected to the cloud Lightning App: {app_name}.") + + if "help" in sys.argv[1]: + _list_app_commands() + else: + _run_app_command(app_name, app_id) + else: + _main() @click.group() @@ -66,6 +83,10 @@ def show(): pass +_main.command(connect) +_main.command(disconnect) + + @show.command() @click.argument("app_name", required=False) @click.argument("components", nargs=-1, required=False) @@ -250,6 +271,7 @@ def login(): def logout(): """Log out of your lightning.ai account.""" Auth().clear() + disconnect(logout=True) def _run_app( @@ -341,59 +363,6 @@ def run_app( _run_app(file, cloud, cluster_id, without_server, no_cache, name, blocking, open_ui, env) -def app_command(): - """Execute a function in a running application from its name.""" - from lightning_app.utilities.commands.base import _download_command - - logger.warn("Lightning Commands are a beta feature and APIs aren't stable yet.") - - debug_mode = bool(int(os.getenv("DEBUG", "0"))) - - parser = ArgumentParser() - parser.add_argument("--app_id", default=None, type=str, help="Optional argument to identify an application.") - hparams, argv = parser.parse_known_args() - - # 1: Collect the url and comments from the running application - url, api_commands = _retrieve_application_url_and_available_commands(hparams.app_id) - if url is None or api_commands is None: - raise Exception("We couldn't find any matching running app.") - - if not api_commands: - raise Exception("This application doesn't expose any commands yet.") - - command = argv[0] - - if command not in api_commands: - raise Exception(f"The provided command {command} isn't available in {list(api_commands)}") - - # 2: Send the command from the user - metadata = api_commands[command] - - # 3: Execute the command - if metadata["tag"] == OpenAPITags.APP_COMMAND: - # TODO: Improve what is current supported - kwargs = [v.replace("--", "") for v in argv[1:]] - - for p in kwargs: - if p.split("=")[0] not in metadata["parameters"]: - raise Exception(f"Some arguments need to be provided. The keys are {list(metadata['parameters'])}.") - # TODO: Encode the parameters and validate their type. - query_parameters = "&".join(kwargs) - resp = requests.post(url + f"/command/{command}?{query_parameters}") - assert resp.status_code == 200, resp.json() - else: - client_command = _download_command( - command, - metadata["cls_path"], - metadata["cls_name"], - hparams.app_id, - debug_mode=debug_mode, - ) - client_command._setup(command_name=command, app_url=url) - sys.argv = argv - client_command.run() - - @_main.group(hidden=True) def fork(): """Fork an application.""" diff --git a/src/lightning_app/testing/testing.py b/src/lightning_app/testing/testing.py index 1ccc8ba1ff63c..387592a4c178e 100644 --- a/src/lightning_app/testing/testing.py +++ b/src/lightning_app/testing/testing.py @@ -184,7 +184,10 @@ def run_app_in_cloud(app_folder: str, app_name: str = "app.py", extra_args: [str else: name = f"test-{TEST_APP_NAME}-" + str(int(time.time())) - # 3. Launch the application in the cloud from the Lightning CLI. + # 3. Disconnect from the App if any. + Popen("lightning disconnect", shell=True).wait() + + # 4. Launch the application in the cloud from the Lightning CLI. with tempfile.TemporaryDirectory() as tmpdir: env_copy = os.environ.copy() env_copy["PACKAGE_LIGHTNING"] = "1" @@ -214,10 +217,10 @@ def run_app_in_cloud(app_folder: str, app_name: str = "app.py", extra_args: [str ) process.wait() - # 4. Print your application name + # 5. Print your application name print(f"The Lightning App Name is: [bold magenta]{name}[/bold magenta]") - # 5. Create chromium browser, auth to lightning_app.ai and yield the admin and view pages. + # 6. Create chromium browser, auth to lightning_app.ai and yield the admin and view pages. with sync_playwright() as p: browser = p.chromium.launch(headless=bool(int(os.getenv("HEADLESS", "0")))) payload = {"apiKey": Config.api_key, "username": Config.username, "duration": "120000"} @@ -343,7 +346,7 @@ def on_error_callback(ws_app, *_): print(f"[{color}]{log_event.component_name}{padding}[/{color}] {date} {message}") yield message - # 5. Print your application ID + # 7. Print your application ID print( f"The Lightning Id Name : [bold magenta]{str(view_page.url).split('.')[0].split('//')[-1]}[/bold magenta]" ) @@ -377,6 +380,8 @@ def on_error_callback(ws_app, *_): except ApiException as e: print(f"Failed to delete {lightningapp.name}. Exception {e}") + Popen("lightning disconnect", shell=True).wait() + def wait_for(page, callback: Callable, *args, **kwargs) -> Any: import playwright diff --git a/src/lightning_app/utilities/cli_helpers.py b/src/lightning_app/utilities/cli_helpers.py index 068024b783bd5..5c885360ce11f 100644 --- a/src/lightning_app/utilities/cli_helpers.py +++ b/src/lightning_app/utilities/cli_helpers.py @@ -86,23 +86,21 @@ def _retrieve_application_url_and_available_commands(app_id_or_name_or_url: Opti resp = requests.get(url + "/openapi.json") if resp.status_code != 200: raise Exception(f"The server didn't process the request properly. Found {resp.json()}") - return url, _extract_command_from_openapi(resp.json()) + return url, _extract_command_from_openapi(resp.json()), None # 2: If no identifier has been provided, evaluate the local application - failed_locally = False - if app_id_or_name_or_url is None: try: url = f"http://localhost:{APP_SERVER_PORT}" resp = requests.get(f"{url}/openapi.json") if resp.status_code != 200: raise Exception(f"The server didn't process the request properly. Found {resp.json()}") - return url, _extract_command_from_openapi(resp.json()) + return url, _extract_command_from_openapi(resp.json()), None except requests.exceptions.ConnectionError: - failed_locally = True + pass # 3: If an identified was provided or the local evaluation has failed, evaluate the cloud. - if app_id_or_name_or_url or failed_locally: + else: client = LightningClient() project = _get_project(client) list_lightningapps = client.lightningapp_instance_service_list_lightningapp_instances(project.project_id) @@ -119,8 +117,8 @@ def _retrieve_application_url_and_available_commands(app_id_or_name_or_url: Opti resp = requests.get(lightningapp.status.url + "/openapi.json") if resp.status_code != 200: raise Exception(f"The server didn't process the request properly. Found {resp.json()}") - return lightningapp.status.url, _extract_command_from_openapi(resp.json()) - return None, None + return lightningapp.status.url, _extract_command_from_openapi(resp.json()), lightningapp.id + return None, None, None def _arrow_time_callback( diff --git a/src/lightning_app/utilities/commands/base.py b/src/lightning_app/utilities/commands/base.py index c74926f542744..512858f8d0ab9 100644 --- a/src/lightning_app/utilities/commands/base.py +++ b/src/lightning_app/utilities/commands/base.py @@ -30,6 +30,9 @@ def makedirs(path: str): class ClientCommand: + + DESCRIPTION = "" + def __init__(self, method: Callable, requirements: Optional[List[str]] = None) -> None: self.method = method flow = getattr(method, "__self__", None) @@ -58,7 +61,8 @@ def run(self, **cli_kwargs) -> None: """Overrides with the logic to execute on the client side.""" def invoke_handler(self, config: BaseModel) -> Dict[str, Any]: - resp = requests.post(self.app_url + f"/command/{self.command_name}", data=config.json()) + command = self.command_name.replace(" ", "_") + resp = requests.post(self.app_url + f"/command/{command}", data=config.json()) assert resp.status_code == 200, resp.json() return resp.json() @@ -75,31 +79,39 @@ def _download_command( cls_name: str, app_id: Optional[str] = None, debug_mode: bool = False, + target_file: Optional[str] = None, ) -> ClientCommand: # TODO: This is a skateboard implementation and the final version will rely on versioned # immutable commands for security concerns - tmpdir = osp.join(gettempdir(), f"{getuser()}_commands") - makedirs(tmpdir) - target_file = osp.join(tmpdir, f"{command_name}.py") - if app_id: - client = LightningClient() - project_id = _get_project(client).project_id - response = client.lightningapp_instance_service_list_lightningapp_instance_artifacts(project_id, app_id) - for artifact in response.artifacts: - if f"commands/{command_name}.py" == artifact.filename: - r = requests.get(artifact.url, allow_redirects=True) - with open(target_file, "wb") as f: - f.write(r.content) - else: - if not debug_mode: + command_name = command_name.replace(" ", "_") + tmpdir = None + if not target_file: + tmpdir = osp.join(gettempdir(), f"{getuser()}_commands") + makedirs(tmpdir) + target_file = osp.join(tmpdir, f"{command_name}.py") + + if not debug_mode: + if app_id: + if not os.path.exists(target_file): + client = LightningClient() + project_id = _get_project(client).project_id + response = client.lightningapp_instance_service_list_lightningapp_instance_artifacts(project_id, app_id) + for artifact in response.artifacts: + if f"commands/{command_name}.py" == artifact.filename: + resp = requests.get(artifact.url, allow_redirects=True) + + with open(target_file, "wb") as f: + f.write(resp.content) + else: shutil.copy(cls_path, target_file) - spec = spec_from_file_location(cls_name, cls_path if debug_mode else target_file) + spec = spec_from_file_location(cls_name, target_file) mod = module_from_spec(spec) sys.modules[cls_name] = mod spec.loader.exec_module(mod) command = getattr(mod, cls_name)(method=None, requirements=[]) - shutil.rmtree(tmpdir) + if tmpdir and os.path.exists(tmpdir): + shutil.rmtree(tmpdir) return command @@ -184,6 +196,7 @@ def _process_api_request(app, request: APIRequest) -> None: def _process_command_requests(app, request: CommandRequest) -> None: for command in app.commands: for command_name, method in command.items(): + command_name = command_name.replace(" ", "_") if request.method_name == command_name: # 2.1: Evaluate the method associated to a specific command. # Validation is done on the CLI side. @@ -213,6 +226,7 @@ def _commands_to_api(commands: List[Dict[str, Union[Callable, ClientCommand]]]) api = [] for command in commands: for k, v in command.items(): + k = k.replace(" ", "_") api.append( Post( f"/command/{k}", diff --git a/tests/tests_app/cli/jsons/connect_1.json b/tests/tests_app/cli/jsons/connect_1.json new file mode 100644 index 0000000000000..dc605a6354c8c --- /dev/null +++ b/tests/tests_app/cli/jsons/connect_1.json @@ -0,0 +1 @@ +{"openapi":"3.0.2","info":{"title":"FastAPI","version":"0.1.0"},"paths":{"/api/v1/state":{"get":{"summary":"Get State","operationId":"get_state_api_v1_state_get","parameters":[{"required":false,"schema":{"title":"X-Lightning-Type","type":"string"},"name":"x-lightning-type","in":"header"},{"required":false,"schema":{"title":"X-Lightning-Session-Uuid","type":"string"},"name":"x-lightning-session-uuid","in":"header"},{"required":false,"schema":{"title":"X-Lightning-Session-Id","type":"string"},"name":"x-lightning-session-id","in":"header"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"summary":"Post State","operationId":"post_state_api_v1_state_post","parameters":[{"required":false,"schema":{"title":"X-Lightning-Type","type":"string"},"name":"x-lightning-type","in":"header"},{"required":false,"schema":{"title":"X-Lightning-Session-Uuid","type":"string"},"name":"x-lightning-session-uuid","in":"header"},{"required":false,"schema":{"title":"X-Lightning-Session-Id","type":"string"},"name":"x-lightning-session-id","in":"header"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/spec":{"get":{"summary":"Get Spec","operationId":"get_spec_api_v1_spec_get","parameters":[{"required":false,"schema":{"title":"X-Lightning-Session-Uuid","type":"string"},"name":"x-lightning-session-uuid","in":"header"},{"required":false,"schema":{"title":"X-Lightning-Session-Id","type":"string"},"name":"x-lightning-session-id","in":"header"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/delta":{"post":{"summary":"Post Delta","description":"This endpoint is used to make an update to the app state using delta diff, mainly used by streamlit to\nupdate the state.","operationId":"post_delta_api_v1_delta_post","parameters":[{"required":false,"schema":{"title":"X-Lightning-Type","type":"string"},"name":"x-lightning-type","in":"header"},{"required":false,"schema":{"title":"X-Lightning-Session-Uuid","type":"string"},"name":"x-lightning-session-uuid","in":"header"},{"required":false,"schema":{"title":"X-Lightning-Session-Id","type":"string"},"name":"x-lightning-session-id","in":"header"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/healthz":{"get":{"summary":"Healthz","description":"Health check endpoint used in the cloud FastAPI servers to check the status periodically. This requires\nRedis to be installed for it to work.\n\n# TODO - Once the state store abstraction is in, check that too","operationId":"healthz_healthz_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/user/command_without_client":{"post":{"tags":["app_api"],"summary":"Command Without Client","operationId":"command_without_client_user_command_without_client_post","parameters":[{"required":true,"schema":{"title":"Name","type":"string"},"name":"name","in":"query"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/command/command_without_client":{"post":{"tags":["app_command"],"summary":"Command Without Client","operationId":"command_without_client_command_command_without_client_post","parameters":[{"required":true,"schema":{"title":"Name","type":"string"},"name":"name","in":"query"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/command/command_with_client":{"post":{"tags":["app_client_command"],"summary":"Command With Client","operationId":"command_with_client_command_command_with_client_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomConfig"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"cls_name":"CustomCommand","cls_path":"examples/app_commands_and_api/command.py"}},"/command/nested_command":{"post":{"tags":["app_command"],"summary":"Nested Command","operationId":"nested_command_command_nested_command_post","parameters":[{"required":true,"schema":{"title":"Name","type":"string"},"name":"name","in":"query"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api{full_path}":{"get":{"summary":"Api Catch All","operationId":"api_catch_all_api_full_path__get","parameters":[{"required":true,"schema":{"title":"Full Path","type":"string"},"name":"full_path","in":"path"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/{full_path}":{"get":{"summary":"Frontend Route","operationId":"frontend_route__full_path__get","parameters":[{"required":true,"schema":{"title":"Full Path","type":"string"},"name":"full_path","in":"path"}],"responses":{"200":{"description":"Successful Response","content":{"text/html":{"schema":{"type":"string"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"CustomConfig":{"title":"CustomConfig","required":["name"],"type":"object","properties":{"name":{"title":"Name","type":"string"}}},"HTTPValidationError":{"title":"HTTPValidationError","type":"object","properties":{"detail":{"title":"Detail","type":"array","items":{"$ref":"#/components/schemas/ValidationError"}}}},"ValidationError":{"title":"ValidationError","required":["loc","msg","type"],"type":"object","properties":{"loc":{"title":"Location","type":"array","items":{"anyOf":[{"type":"string"},{"type":"integer"}]}},"msg":{"title":"Message","type":"string"},"type":{"title":"Error Type","type":"string"}}}}}} diff --git a/tests/tests_app/cli/test_connect.py b/tests/tests_app/cli/test_connect.py new file mode 100644 index 0000000000000..cfe95740a2020 --- /dev/null +++ b/tests/tests_app/cli/test_connect.py @@ -0,0 +1,190 @@ +import json +import os +import sys +from unittest.mock import MagicMock + +import click +import pytest +import requests + +from lightning_app import _PACKAGE_ROOT +from lightning_app.cli.commands.connection import ( + _list_app_commands, + _resolve_command_path, + _retrieve_connection_to_an_app, + connect, + disconnect, +) +from lightning_app.utilities import cli_helpers +from lightning_app.utilities.commands import base + + +def test_connect_disconnect_local(monkeypatch): + + disconnect() + + with pytest.raises(Exception, match="The commands weren't found. Is your app localhost running ?"): + connect("localhost", True) + + with open(os.path.join(os.path.dirname(__file__), "jsons/connect_1.json")) as f: + data = json.load(f) + + data["paths"]["/command/command_with_client"]["post"]["cls_path"] = os.path.join( + os.path.dirname(os.path.dirname(_PACKAGE_ROOT)), + data["paths"]["/command/command_with_client"]["post"]["cls_path"], + ) + + messages = [] + + def fn(msg): + messages.append(msg) + + monkeypatch.setattr(click, "echo", fn) + + response = MagicMock() + response.status_code = 200 + response.json.return_value = data + monkeypatch.setattr(requests, "get", MagicMock(return_value=response)) + connect("localhost", True) + assert _retrieve_connection_to_an_app() == ("localhost", None) + commands = _list_app_commands() + assert commands == ["command with client", "command without client", "nested command"] + command_path = _resolve_command_path("nested_command") + assert not os.path.exists(command_path) + command_path = _resolve_command_path("command_with_client") + assert os.path.exists(command_path) + home = os.path.expanduser("~") + s = "/" if sys.platform != "win32" else "\\" + command_folder_path = f"{home}{s}.lightning{s}lightning_connection{s}commands" + expected = [ + f"Storing `command_with_client` under {command_folder_path}{s}command_with_client.py", + f"You can review all the downloaded commands under {command_folder_path} folder.", + "You are connected to the local Lightning App.", + "Usage: lightning [OPTIONS] COMMAND [ARGS]...", + "", + " --help Show this message and exit.", + "", + "Lightning App Commands", + " command with client Description", + " command without client Description", + " nested command Description", + ] + assert messages == expected + + messages = [] + connect("localhost", True) + assert messages == ["You are connected to the local Lightning App."] + + messages = [] + disconnect() + assert messages == ["You are disconnected from the local Lightning App."] + messages = [] + disconnect() + assert messages == [ + "You aren't connected to any Lightning App. Please use `lightning connect app_name_or_id` to connect to one." + ] + + assert _retrieve_connection_to_an_app() == (None, None) + + +def test_connect_disconnect_cloud(monkeypatch): + + disconnect() + + target_file = _resolve_command_path("command_with_client") + + if os.path.exists(target_file): + os.remove(target_file) + + with open(os.path.join(os.path.dirname(__file__), "jsons/connect_1.json")) as f: + data = json.load(f) + + data["paths"]["/command/command_with_client"]["post"]["cls_path"] = os.path.join( + os.path.dirname(os.path.dirname(_PACKAGE_ROOT)), + data["paths"]["/command/command_with_client"]["post"]["cls_path"], + ) + + messages = [] + + def fn(msg): + messages.append(msg) + + monkeypatch.setattr(click, "echo", fn) + + response = MagicMock() + response.status_code = 200 + response.json.return_value = data + monkeypatch.setattr(requests, "get", MagicMock(return_value=response)) + project = MagicMock() + project.project_id = "custom_project_name" + monkeypatch.setattr(cli_helpers, "_get_project", MagicMock(return_value=project)) + client = MagicMock() + lightningapps = MagicMock() + + app = MagicMock() + app.name = "example" + app.id = "1234" + + lightningapps.lightningapps = [app] + client.lightningapp_instance_service_list_lightningapp_instances.return_value = lightningapps + monkeypatch.setattr(cli_helpers, "LightningClient", MagicMock(return_value=client)) + + monkeypatch.setattr(base, "_get_project", MagicMock(return_value=project)) + + artifact = MagicMock() + artifact.filename = "commands/command_with_client.py" + artifacts = MagicMock() + artifacts.artifacts = [artifact] + client.lightningapp_instance_service_list_lightningapp_instance_artifacts.return_value = artifacts + monkeypatch.setattr(base, "LightningClient", MagicMock(return_value=client)) + + with open(data["paths"]["/command/command_with_client"]["post"]["cls_path"], "rb") as f: + response.content = f.read() + + connect("example", True) + assert _retrieve_connection_to_an_app() == ("example", "1234") + commands = _list_app_commands() + assert commands == ["command with client", "command without client", "nested command"] + command_path = _resolve_command_path("nested_command") + assert not os.path.exists(command_path) + command_path = _resolve_command_path("command_with_client") + assert os.path.exists(command_path) + home = os.path.expanduser("~") + s = "/" if sys.platform != "win32" else "\\" + command_folder_path = f"{home}{s}.lightning{s}lightning_connection{s}commands" + expected = [ + f"Storing `command_with_client` under {command_folder_path}{s}command_with_client.py", + f"You can review all the downloaded commands under {command_folder_path} folder.", + " ", + "The client interface has been successfully installed. ", + "You can now run the following commands:", + " lightning command_without_client", + " lightning command_with_client", + " lightning nested_command", + " ", + "You are connected to the cloud Lightning App: example.", + "Usage: lightning [OPTIONS] COMMAND [ARGS]...", + "", + " --help Show this message and exit.", + "", + "Lightning App Commands", + " command with client Description", + " command without client Description", + " nested command Description", + ] + assert messages == expected + + messages = [] + connect("example", True) + assert messages == ["You are already connected to the cloud Lightning App: example."] + + messages = [] + disconnect() + assert messages == ["You are disconnected from the cloud Lightning App: example."] + messages = [] + disconnect() + assert messages == [ + "You aren't connected to any Lightning App. Please use `lightning connect app_name_or_id` to connect to one." + ] + + assert _retrieve_connection_to_an_app() == (None, None) diff --git a/tests/tests_app/utilities/test_commands.py b/tests/tests_app/utilities/test_commands.py index 1be35a3a2e290..2e14f580c1122 100644 --- a/tests/tests_app/utilities/test_commands.py +++ b/tests/tests_app/utilities/test_commands.py @@ -10,7 +10,8 @@ from lightning import LightningFlow from lightning_app import LightningApp -from lightning_app.cli.lightning_cli import app_command +from lightning_app.cli.commands.connection import connect, disconnect +from lightning_app.cli.lightning_cli import _run_app_command from lightning_app.core.constants import APP_SERVER_PORT from lightning_app.runners import MultiProcessRuntime from lightning_app.testing.helpers import RunIf @@ -54,7 +55,7 @@ def sweep(self, config: SweepConfig): return True def configure_commands(self): - return [{"user_command": self.trigger_method}, {"sweep": SweepCommand(self.sweep)}] + return [{"user command": self.trigger_method}, {"sweep": SweepCommand(self.sweep)}] class DummyConfig(BaseModel): @@ -130,6 +131,7 @@ def target(): def test_configure_commands(monkeypatch): + """This test validates command can be used locally with connect and disconnect.""" process = Process(target=target) process.start() time_left = 15 @@ -142,14 +144,15 @@ def test_configure_commands(monkeypatch): time_left -= 0.1 sleep(0.5) - monkeypatch.setattr(sys, "argv", ["lightning", "user_command", "--name=something"]) - app_command() + monkeypatch.setattr(sys, "argv", ["lightning", "user", "command", "--name=something"]) + connect("localhost") + _run_app_command("localhost", None) sleep(0.5) state = AppState() state._request_state() assert state.names == ["something"] monkeypatch.setattr(sys, "argv", ["lightning", "sweep", "--sweep_name=my_name", "--num_trials=1"]) - app_command() + _run_app_command("localhost", None) time_left = 15 while time_left > 0: if process.exitcode == 0: @@ -157,3 +160,4 @@ def test_configure_commands(monkeypatch): sleep(0.1) time_left -= 0.1 assert process.exitcode == 0 + disconnect() diff --git a/tests/tests_app_examples/idle_timeout/app.py b/tests/tests_app_examples/idle_timeout/app.py index ff45f5332bcaa..218c9e0174d08 100644 --- a/tests/tests_app_examples/idle_timeout/app.py +++ b/tests/tests_app_examples/idle_timeout/app.py @@ -49,7 +49,7 @@ def run(self): assert stopped_status_sigterm.stage == WorkStageStatus.STOPPED assert stopped_status_sigterm.reason == WorkStopReasons.SIGTERM_SIGNAL_HANDLER # Note: Account for the controlplane, k8s, SIGTERM handler delays. - assert (stopped_status_pending.timestamp - succeeded_status.timestamp) < 10 + assert (stopped_status_pending.timestamp - succeeded_status.timestamp) < 20 assert (stopped_status_sigterm.timestamp - stopped_status_pending.timestamp) < 120 fs = filesystem() destination_path = artifacts_path(self.work) / pathlib.Path(*self.work.path.resolve().parts[1:]) diff --git a/tests/tests_app_examples/test_commands_and_api.py b/tests/tests_app_examples/test_commands_and_api.py index 8d84cf4847ebd..8fe3d024c8343 100644 --- a/tests/tests_app_examples/test_commands_and_api.py +++ b/tests/tests_app_examples/test_commands_and_api.py @@ -20,15 +20,18 @@ def test_commands_and_api_example_cloud() -> None: # 1: Collect the app_id app_id = admin_page.url.split("/")[-1] - # 2: Send the first command with the client - cmd = f"lightning command_with_client --name=this --app_id {app_id}" + # 2: Connect to the App + Popen(f"lightning connect {app_id} -y", shell=True).wait() + + # 3: Send the first command with the client + cmd = "lightning command with client --name=this" Popen(cmd, shell=True).wait() - # 3: Send the second command without a client - cmd = f"lightning command_without_client --name=is --app_id {app_id}" + # 4: Send the second command without a client + cmd = "lightning command without client --name=is" Popen(cmd, shell=True).wait() - # 4: Send a request to the Rest API directly. + # 5: Send a request to the Rest API directly. base_url = view_page.url.replace("/view", "").replace("/child_flow", "") resp = requests.post(base_url + "/user/command_without_client?name=awesome") assert resp.status_code == 200, resp.json() @@ -40,3 +43,6 @@ def test_commands_and_api_example_cloud() -> None: if "['this', 'is', 'awesome']" in log: has_logs = True sleep(1) + + # 5: Disconnect from the App + Popen("lightning disconnect", shell=True).wait()