diff --git a/docs/source-app/workflows/build_command_line_interface/cli.rst b/docs/source-app/workflows/build_command_line_interface/cli.rst index 4608e5675b..c199b3129d 100644 --- a/docs/source-app/workflows/build_command_line_interface/cli.rst +++ b/docs/source-app/workflows/build_command_line_interface/cli.rst @@ -64,7 +64,7 @@ To see a list of available commands: --help Show this message and exit. Lightning App Commands - add Description + add Add a name. To find the arguments of the commands: 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 index 96a2b41195..4ff6ab4250 100644 --- a/docs/source-app/workflows/build_command_line_interface/cli_client.rst +++ b/docs/source-app/workflows/build_command_line_interface/cli_client.rst @@ -80,7 +80,7 @@ To see a list of available commands: --help Show this message and exit. Lightning App Commands - run notebook Description + run notebook Run a Notebook. To find the arguments of the commands: 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 index 4e3bc67d9e..1c532e3c27 100644 --- 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 @@ -13,6 +13,8 @@ class RunNotebookConfig(BaseModel): class RunNotebook(ClientCommand): + DESCRIPTION = "Run a Notebook." + def run(self): # 1. Define your own argument parser. You can use argparse, click, etc... parser = ArgumentParser(description='Run Notebook Parser') 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 index 3c013548af..4d837fc007 100644 --- a/docs/source-app/workflows/build_command_line_interface/example_command.py +++ b/docs/source-app/workflows/build_command_line_interface/example_command.py @@ -10,6 +10,7 @@ class Flow(LightningFlow): print(self.names) def add_name(self, name: str): + """Add a name.""" print(f"Received name: {name}") self.names.append(name) diff --git a/examples/app_commands_and_api/app.py b/examples/app_commands_and_api/app.py index 057e137912..d3529e0d8c 100644 --- a/examples/app_commands_and_api/app.py +++ b/examples/app_commands_and_api/app.py @@ -7,6 +7,7 @@ from lightning_app.core.app import LightningApp class ChildFlow(LightningFlow): def nested_command(self, name: str): + """A nested command.""" print(f"Hello {name}") def configure_commands(self): @@ -24,6 +25,7 @@ class FlowCommands(LightningFlow): print(self.names) def command_without_client(self, name: str): + """A command without a client.""" self.names.append(name) def command_with_client(self, config: CustomConfig): diff --git a/examples/app_commands_and_api/command.py b/examples/app_commands_and_api/command.py index 8c3070f6d7..b77edab9af 100644 --- a/examples/app_commands_and_api/command.py +++ b/examples/app_commands_and_api/command.py @@ -10,6 +10,9 @@ class CustomConfig(BaseModel): class CustomCommand(ClientCommand): + + DESCRIPTION = "A command with a client." + def run(self): parser = ArgumentParser() parser.add_argument("--name", type=str) diff --git a/src/lightning_app/CHANGELOG.md b/src/lightning_app/CHANGELOG.md index c646f3b262..14afb2ccb0 100644 --- a/src/lightning_app/CHANGELOG.md +++ b/src/lightning_app/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - Added a `--secret` option to CLI to allow binding secrets to app environment variables when running in the cloud ([#14612](https://github.com/Lightning-AI/lightning/pull/14612)) - Added support for running the works without cloud compute in the default container ([#14819](https://github.com/Lightning-AI/lightning/pull/14819)) - Added an HTTPQueue as an optional replacement for the default redis queue ([#14978](https://github.com/Lightning-AI/lightning/pull/14978) +- Added support for adding descriptions to commands either through a docstring or the `DESCRIPTION` attribute ([#15193](https://github.com/Lightning-AI/lightning/pull/15193) ### Fixed diff --git a/src/lightning_app/cli/commands/connection.py b/src/lightning_app/cli/commands/connection.py index 625b5b9fb3..618f41187c 100644 --- a/src/lightning_app/cli/commands/connection.py +++ b/src/lightning_app/cli/commands/connection.py @@ -1,3 +1,4 @@ +import json import os import shutil from typing import List, Optional, Tuple @@ -50,6 +51,8 @@ def connect(app_name_or_id: str, yes: bool = False): if not os.path.exists(commands_folder): os.makedirs(commands_folder) + _write_commands_metadata(api_commands) + 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") @@ -99,6 +102,8 @@ def connect(app_name_or_id: str, yes: bool = False): if not os.path.exists(commands_folder): os.makedirs(commands_folder) + _write_commands_metadata(api_commands) + for command_name, metadata in api_commands.items(): if "cls_path" in metadata: target_file = os.path.join(commands_folder, f"{command_name}.py") @@ -174,16 +179,28 @@ def _get_commands_folder() -> str: return os.path.join(lightning_folder, "commands") +def _write_commands_metadata(api_commands): + metadata = {command_name: metadata for command_name, metadata in api_commands.items()} + metadata_path = os.path.join(_get_commands_folder(), ".meta.json") + with open(metadata_path, "w") as f: + json.dump(metadata, f) + + +def _get_commands_metadata(): + metadata_path = os.path.join(_get_commands_folder(), ".meta.json") + with open(metadata_path) as f: + return json.load(f) + + 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__" - ) + metadata = _get_commands_metadata() + metadata = {key.replace("_", " "): value for key, value in metadata.items()} + + command_names = list(sorted(metadata.keys())) if not command_names: click.echo("The current Lightning App doesn't have commands.") return [] @@ -196,5 +213,5 @@ def _list_app_commands() -> List[str]: 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") + click.echo(f" {command_name}{padding}{metadata[command_name].get('description', '')}") return command_names diff --git a/src/lightning_app/utilities/cli_helpers.py b/src/lightning_app/utilities/cli_helpers.py index 41bffaa743..ff2cd15082 100644 --- a/src/lightning_app/utilities/cli_helpers.py +++ b/src/lightning_app/utilities/cli_helpers.py @@ -56,6 +56,7 @@ def _get_metadata_from_openapi(paths: Dict, path: str): tag = paths[path]["post"].get("tags", [None])[0] cls_path = paths[path]["post"].get("cls_path", None) cls_name = paths[path]["post"].get("cls_name", None) + description = paths[path]["post"].get("description", None) metadata = {"tag": tag, "parameters": {}} @@ -65,6 +66,9 @@ def _get_metadata_from_openapi(paths: Dict, path: str): if cls_name: metadata["cls_name"] = cls_name + if description: + metadata["description"] = description + if not parameters: return metadata diff --git a/src/lightning_app/utilities/commands/base.py b/src/lightning_app/utilities/commands/base.py index 5138fc467d..eb077b263d 100644 --- a/src/lightning_app/utilities/commands/base.py +++ b/src/lightning_app/utilities/commands/base.py @@ -35,6 +35,8 @@ class ClientCommand: def __init__(self, method: Callable, requirements: Optional[List[str]] = None) -> None: self.method = method + if not self.DESCRIPTION: + self.DESCRIPTION = self.method.__doc__ or "" flow = getattr(method, "__self__", None) self.owner = flow.name if flow else None self.requirements = requirements @@ -218,10 +220,13 @@ def _process_requests(app, request: Union[APIRequest, CommandRequest]) -> None: def _collect_open_api_extras(command) -> Dict: if not isinstance(command, ClientCommand): + if command.__doc__ is not None: + return {"description": command.__doc__} return {} return { "cls_path": inspect.getfile(command.__class__), "cls_name": command.__class__.__name__, + "description": command.DESCRIPTION, } diff --git a/tests/tests_app/cli/jsons/connect_1.json b/tests/tests_app/cli/jsons/connect_1.json index dc605a6354..2a7a210674 100644 --- a/tests/tests_app/cli/jsons/connect_1.json +++ b/tests/tests_app/cli/jsons/connect_1.json @@ -1 +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"}}}}}} +{"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"}}}}}}},"/api/v1/upload_file/{filename}":{"put":{"summary":"Upload File","operationId":"upload_file_api_v1_upload_file__filename__put","parameters":[{"required":true,"schema":{"title":"Filename","type":"string"},"name":"filename","in":"path"}],"requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_upload_file_api_v1_upload_file__filename__put"}}},"required":true},"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.","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","description":"A command without a 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","description":"A command with a 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_path":"examples/app_commands_and_api/command.py","cls_name":"CustomCommand"}},"/command/nested_command":{"post":{"tags":["app_command"],"summary":"Nested Command","description":"A 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":{"Body_upload_file_api_v1_upload_file__filename__put":{"title":"Body_upload_file_api_v1_upload_file__filename__put","required":["uploaded_file"],"type":"object","properties":{"uploaded_file":{"title":"Uploaded File","type":"string","format":"binary"}}},"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 index cfe95740a2..e02bdcd377 100644 --- a/tests/tests_app/cli/test_connect.py +++ b/tests/tests_app/cli/test_connect.py @@ -65,9 +65,9 @@ def test_connect_disconnect_local(monkeypatch): " --help Show this message and exit.", "", "Lightning App Commands", - " command with client Description", - " command without client Description", - " nested command Description", + " command with client A command with a client.", + " command without client A command without a client.", + " nested command A nested command.", ] assert messages == expected @@ -168,9 +168,9 @@ def test_connect_disconnect_cloud(monkeypatch): " --help Show this message and exit.", "", "Lightning App Commands", - " command with client Description", - " command without client Description", - " nested command Description", + " command with client A command with a client.", + " command without client A command without a client.", + " nested command A nested command.", ] assert messages == expected