Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[App] Enable help without running application #15196

Merged
merged 18 commits into from
Oct 20, 2022
2 changes: 1 addition & 1 deletion src/lightning_app/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
- 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)

- Added support getting CLI help even if the app isn't running ([#15196](https://github.com/Lightning-AI/lightning/pull/15196)
Borda marked this conversation as resolved.
Show resolved Hide resolved

### Fixed

Expand Down
27 changes: 17 additions & 10 deletions src/lightning_app/cli/commands/app_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,40 +5,47 @@
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.cli_helpers import LightningAppOpenAPIRetriever
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.")
running_help = sys.argv[-1] == "--help"

if not api_commands:
retriever = LightningAppOpenAPIRetriever(app_id, use_cache=running_help)

if not running_help and (retriever.url is None or retriever.api_commands is None):
if app_name == "localhost":
print("The command couldn't be executed as your local Lightning App isn't running.")
tchaton marked this conversation as resolved.
Show resolved Hide resolved
else:
print(f"The command couldn't be executed as your cloud Lightning App `{app_name}` isn't running.")
sys.exit(0)

if not retriever.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):
for command in list(retriever.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)}")
raise Exception(f"The provided command isn't available in {list(retriever.api_commands)}")

# 2: Send the command from the user
metadata = api_commands[command]
metadata = retriever.api_commands[command]

# 3: Execute the command
if metadata["tag"] == OpenAPITags.APP_COMMAND:
_handle_command_without_client(command, metadata, url)
_handle_command_without_client(command, metadata, retriever.url)
else:
_handle_command_with_client(command, metadata, app_name, app_id, url)
_handle_command_with_client(command, metadata, app_name, app_id, retriever.url)

if sys.argv[-1] != "--help":
print("Your command execution was successful.")
Expand Down
33 changes: 18 additions & 15 deletions src/lightning_app/cli/commands/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import click

from lightning_app.utilities.cli_helpers import _retrieve_application_url_and_available_commands
from lightning_app.utilities.cli_helpers import LightningAppOpenAPIRetriever
from lightning_app.utilities.cloud import _get_project
from lightning_app.utilities.network import LightningClient

Expand Down Expand Up @@ -42,18 +42,21 @@ def connect(app_name_or_id: str, yes: bool = False):
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)
retriever = LightningAppOpenAPIRetriever(None)

if api_commands is None:
if retriever.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)

_write_commands_metadata(api_commands)
_write_commands_metadata(retriever.api_commands)

for command_name, metadata in api_commands.items():
with open(os.path.join(commands_folder, "openapi.json"), "w") as f:
json.dump(retriever.openapi, f)

for command_name, metadata in retriever.api_commands.items():
if "cls_path" in metadata:
target_file = os.path.join(commands_folder, f"{command_name.replace(' ','_')}.py")
_download_command(
Expand All @@ -63,7 +66,8 @@ def connect(app_name_or_id: str, yes: bool = False):
None,
target_file=target_file,
)
click.echo(f"Storing `{command_name}` under {target_file}")
repr_command_name = command_name.replace("_", " ")
click.echo(f"Find the `{repr_command_name}` command 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:
Expand All @@ -74,9 +78,10 @@ def connect(app_name_or_id: str, yes: bool = False):

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:
retriever = LightningAppOpenAPIRetriever(app_name_or_id)

if not retriever.api_commands:
client = LightningClient()
project = _get_project(client)
lightningapps = client.lightningapp_instance_service_list_lightningapp_instances(
Expand All @@ -88,8 +93,6 @@ def connect(app_name_or_id: str, yes: bool = False):
)
return

assert lightningapp_id

if not yes:
yes = click.confirm(
f"The Lightning App `{app_name_or_id}` provides a command-line (CLI). "
Expand All @@ -102,16 +105,16 @@ 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)
_write_commands_metadata(retriever.api_commands)

for command_name, metadata in api_commands.items():
for command_name, metadata in retriever.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,
retriever.app_id,
target_file=target_file,
)
click.echo(f"Storing `{command_name}` under {target_file}")
Expand All @@ -123,12 +126,12 @@ def connect(app_name_or_id: str, yes: bool = False):
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:
for command in retriever.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")
f.write(retriever.app_id + "\n")
click.echo(" ")
click.echo(f"You are connected to the cloud Lightning App: {app_name_or_id}.")

Expand Down
13 changes: 9 additions & 4 deletions src/lightning_app/cli/lightning_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def get_app_url(runtime_type: RuntimeType, *args: Any, need_credits: bool = Fals

def main() -> None:
# 1: Handle connection to a Lightning App.
if len(sys.argv) > 1 and sys.argv[1] in ("connect", "disconnect"):
if len(sys.argv) > 1 and sys.argv[1] in ("connect", "disconnect", "logout"):
_main()
else:
# 2: Collect the connection a Lightning App.
Expand All @@ -62,14 +62,19 @@ def main() -> None:
_main()
else:
if is_local_app:
click.echo("You are connected to the local Lightning App.")
message = "You are connected to the local Lightning App. "
tchaton marked this conversation as resolved.
Show resolved Hide resolved
else:
click.echo(f"You are connected to the cloud Lightning App: {app_name}.")
message = f"You are connected to the cloud Lightning App: {app_name}. "
tchaton marked this conversation as resolved.
Show resolved Hide resolved

if "help" in sys.argv[1]:
click.echo(" ")

if len(sys.argv) > 1 and "help" in sys.argv[1] or len(sys.argv) == 1:
_list_app_commands()
else:
_run_app_command(app_name, app_id)

click.echo()
click.echo(message + "Return to the primary CLI with `lightning disconnect`.")
else:
_main()

Expand Down
126 changes: 96 additions & 30 deletions src/lightning_app/utilities/cli_helpers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import json
import os
import re
from typing import Dict, Optional

Expand Down Expand Up @@ -81,30 +83,51 @@ def _extract_command_from_openapi(openapi_resp: Dict) -> Dict[str, Dict[str, str
return {p.replace("/command/", ""): _get_metadata_from_openapi(openapi_resp["paths"], p) for p in command_paths}


def _retrieve_application_url_and_available_commands(app_id_or_name_or_url: Optional[str]):
"""This function is used to retrieve the current url associated with an id."""

if _is_url(app_id_or_name_or_url):
url = app_id_or_name_or_url
assert url
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()), None

# 2: If no identifier has been provided, evaluate the local application
if app_id_or_name_or_url is None:
try:
class LightningAppOpenAPIRetriever:
def __init__(self, app_id_or_name_or_url: Optional[str], use_cache: bool = True):
self.app_id_or_name_or_url = app_id_or_name_or_url
self.url = None
self.openapi = None
self.api_commands = None
self.app_id = None
home = os.path.expanduser("~")
if use_cache:
cache_openapi = os.path.join(home, ".lightning", "lightning_connection", "commands", "openapi.json")
if os.path.exists(cache_openapi):
with open(cache_openapi) as f:
self.openapi = json.load(f)
self.api_commands = _extract_command_from_openapi(self.openapi)
else:
self._collect_open_api_json()
if self.openapi:
self.api_commands = _extract_command_from_openapi(self.openapi)

def is_alive(self) -> bool:
if self.url is None:
self._find_url()
if self.url is None:
return False
resp = requests.get(self.url)
return resp.status_code == 200

def _find_url(self):
if _is_url(self.app_id_or_name_or_url):
self.url = self.app_id_or_name_or_url
assert self.url
return

if self.app_id_or_name_or_url is None:
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()), None
except requests.exceptions.ConnectionError:
pass
resp = requests.get(f"{self.url}/openapi.json")
if resp.status_code == 200:
self.url = url
return

lightningapp = self._find_matching_app()
if lightningapp:
self.url = lightningapp.status.url

# 3: If an identified was provided or the local evaluation has failed, evaluate the cloud.
else:
def _find_matching_app(self):
client = LightningClient()
project = _get_project(client)
list_lightningapps = client.lightningapp_instance_service_list_lightningapp_instances(
Expand All @@ -113,20 +136,63 @@ def _retrieve_application_url_and_available_commands(app_id_or_name_or_url: Opti

lightningapp_names = [lightningapp.name for lightningapp in list_lightningapps.lightningapps]

if not app_id_or_name_or_url:
if not self.app_id_or_name_or_url:
raise Exception(f"Provide an application name, id or url with --app_id=X. Found {lightningapp_names}")

for lightningapp in list_lightningapps.lightningapps:
if lightningapp.id == app_id_or_name_or_url or lightningapp.name == app_id_or_name_or_url:
if lightningapp.id == self.app_id_or_name_or_url or lightningapp.name == self.app_id_or_name_or_url:
if lightningapp.status.url == "":
raise Exception("The application is starting. Try in a few moments.")
resp = requests.get(lightningapp.status.url + "/openapi.json")
return lightningapp

def _collect_open_api_json(self):
"""This function is used to retrieve the current url associated with an id."""

if _is_url(self.app_id_or_name_or_url):
self.url = self.app_id_or_name_or_url
assert self.url
resp = requests.get(self.url + "/openapi.json")
if resp.status_code != 200:
raise Exception(f"The server didn't process the request properly. Found {resp.json()}")
self.openapi = resp.json()
return

# 2: If no identifier has been provided, evaluate the local application
if self.app_id_or_name_or_url is None:
try:
self.url = f"http://localhost:{APP_SERVER_PORT}"
resp = requests.get(f"{self.url}/openapi.json")
if resp.status_code != 200:
raise Exception(
"The server didn't process the request properly. " "Try once your application is ready."
)
return lightningapp.status.url, _extract_command_from_openapi(resp.json()), lightningapp.id
return None, None, None
raise Exception(f"The server didn't process the request properly. Found {resp.json()}")
self.openapi = resp.json()
except requests.exceptions.ConnectionError:
pass

# 3: If an identified was provided or the local evaluation has failed, evaluate the cloud.
else:
client = LightningClient()
project = _get_project(client)
list_lightningapps = client.lightningapp_instance_service_list_lightningapp_instances(
project_id=project.project_id
)

lightningapp_names = [lightningapp.name for lightningapp in list_lightningapps.lightningapps]

if not self.app_id_or_name_or_url:
raise Exception(f"Provide an application name, id or url with --app_id=X. Found {lightningapp_names}")

for lightningapp in list_lightningapps.lightningapps:
if lightningapp.id == self.app_id_or_name_or_url or lightningapp.name == self.app_id_or_name_or_url:
if lightningapp.status.url == "":
raise Exception("The application is starting. Try in a few moments.")
resp = requests.get(lightningapp.status.url + "/openapi.json")
if resp.status_code != 200:
raise Exception(
"The server didn't process the request properly. " "Try once your application is ready."
)
self.url = lightningapp.status.url
self.openapi = resp.json()
self.app_id = lightningapp.id


def _arrow_time_callback(
Expand Down