diff --git a/nextmv/nextmv/cli/community/clone.py b/nextmv/nextmv/cli/community/clone.py index 646b2ea4..70b0a0e4 100644 --- a/nextmv/nextmv/cli/community/clone.py +++ b/nextmv/nextmv/cli/community/clone.py @@ -15,6 +15,8 @@ from nextmv.cli.community.list import download_file, download_manifest, find_app, versions_table from nextmv.cli.message import error, success from nextmv.cli.options import ProfileOption +from nextmv.local.registry import AppEntry, add_registry_entry +from nextmv.safe import safe_id # Set up subcommand application. app = typer.Typer() @@ -136,6 +138,14 @@ def clone( # Remove the tarball after extraction os.remove(downloaded_object) + # Register as a local app. + add_registry_entry( + AppEntry( + app_id=safe_id(app), + path=os.path.abspath(full_destination), + ), + ) + success( f"Successfully cloned the [magenta]{app}[/magenta] community app, " f"using version [magenta]{original_version}[/magenta] in path: [magenta]{full_destination}[/magenta]." diff --git a/nextmv/nextmv/cli/local/__init__.py b/nextmv/nextmv/cli/local/__init__.py new file mode 100644 index 00000000..b598fbaf --- /dev/null +++ b/nextmv/nextmv/cli/local/__init__.py @@ -0,0 +1,21 @@ +""" +This module defines the local command tree for the Nextmv CLI. +""" + +import typer + +from nextmv.cli.local.app import app as app_app +from nextmv.cli.local.run import app as run_app + +# Set up subcommand application. +app = typer.Typer() +app.add_typer(app_app, name="app") +app.add_typer(run_app, name="run") + + +@app.callback() +def callback() -> None: + """ + Interact with local Nextmv apps and make runs. + """ + pass diff --git a/nextmv/nextmv/cli/local/app/__init__.py b/nextmv/nextmv/cli/local/app/__init__.py new file mode 100644 index 00000000..17d4322c --- /dev/null +++ b/nextmv/nextmv/cli/local/app/__init__.py @@ -0,0 +1,31 @@ +""" +This module defines the local app command tree for the Nextmv CLI. +""" + +import typer + +from nextmv.cli.local.app.delete import app as delete_app +from nextmv.cli.local.app.exists import app as exists_app +from nextmv.cli.local.app.get import app as get_app +from nextmv.cli.local.app.list import app as list_app +from nextmv.cli.local.app.sync import app as sync_app + +# Set up subcommand application. +app = typer.Typer() +app.add_typer(delete_app) +app.add_typer(exists_app) +app.add_typer(get_app) +app.add_typer(list_app) +app.add_typer(sync_app) + + +@app.callback() +def callback() -> None: + """ + Manage and sync local Nextmv applications. + + A Nextmv application is an entity that contains a decision model as + executable code. An application can make a run by taking an input, + executing the decision model, and producing an output. + """ + pass diff --git a/nextmv/nextmv/cli/local/app/delete.py b/nextmv/nextmv/cli/local/app/delete.py new file mode 100644 index 00000000..aa37f9b4 --- /dev/null +++ b/nextmv/nextmv/cli/local/app/delete.py @@ -0,0 +1,72 @@ +""" +This module defines the local app delete command for the Nextmv CLI. +""" + +from typing import Annotated + +import typer + +from nextmv.cli.confirm import get_confirmation +from nextmv.cli.message import info, success +from nextmv.cli.options import AppIDOption, ProfileOption +from nextmv.local.application import Application +from nextmv.local.registry import delete_registry_entry, read_local_registry + +# Set up subcommand application. +app = typer.Typer() + + +@app.command() +def delete( + app_id: AppIDOption, + yes: Annotated[ + bool, + typer.Option( + "--yes", + "-y", + help="Agree to deletion confirmation prompt. Useful for non-interactive sessions.", + ), + ] = False, + profile: ProfileOption = None, +) -> None: + """ + Deletes a local Nextmv application. + + This action is permanent and cannot be undone. Use the --yes + flag to skip the confirmation prompt. + + [bold][underline]Examples[/underline][/bold] + + - Delete the application with the ID [magenta]hare-app[/magenta]. + $ [dim]nextmv local app delete --app-id hare-app[/dim] + + - Delete the application with the ID [magenta]hare-app[/magenta] without confirmation prompt. + $ [dim]nextmv local app delete --app-id hare-app --yes[/dim] + """ + + if not yes: + confirm = get_confirmation( + f"Are you sure you want to delete application [magenta]{app_id}[/magenta]? This action cannot be undone.", + ) + + if not confirm: + info(msg=f"Application [magenta]{app_id}[/magenta] will not be deleted.", emoji=":bulb:") + return + + # Find app in registry. + registry = read_local_registry() + app_entry = next((app for app in registry.apps if app.app_id == app_id), None) + if app_entry is None: + typer.echo(f"Application with ID '{app_id}' not found.") + raise typer.Exit(code=1) + app = Application(src=app_entry.path) + + # Make sure the app exists before attempting to delete. + if not app.exists(): + typer.echo(f"Application with ID '{app_id}' not found at path '{app_entry.path}'.") + raise typer.Exit(code=1) + + # Delete the app. + app.delete() + delete_registry_entry(app_id) + success(f"Application [magenta]{app_id}[/magenta] deleted successfully.") diff --git a/nextmv/nextmv/cli/local/app/exists.py b/nextmv/nextmv/cli/local/app/exists.py new file mode 100644 index 00000000..e394a126 --- /dev/null +++ b/nextmv/nextmv/cli/local/app/exists.py @@ -0,0 +1,35 @@ +""" +This module defines the cloud app exists command for the Nextmv CLI. +""" + +import typer + +from nextmv.cli.message import in_progress, print_json +from nextmv.cli.options import AppIDOption +from nextmv.local.registry import read_local_registry + +# Set up subcommand application. +app = typer.Typer() + + +@app.command() +def exists( + app_id: AppIDOption, +) -> None: + """ + Check if a local Nextmv application exists. + + This command is useful in scripting applications to verify the existence of + a local application by its ID. + + [bold][underline]Examples[/underline][/bold] + + - Check if the application with the ID [magenta]hare-app[/magenta] exists. + $ [dim]nextmv local app exists --app-id hare-app[/dim] + """ + + registry = read_local_registry() + in_progress(msg="Checking if application exists...") + + ok = any(app.app_id == app_id for app in registry.apps) + print_json({"exists": ok}) diff --git a/nextmv/nextmv/cli/local/app/get.py b/nextmv/nextmv/cli/local/app/get.py new file mode 100644 index 00000000..54506a23 --- /dev/null +++ b/nextmv/nextmv/cli/local/app/get.py @@ -0,0 +1,68 @@ +""" +This module defines the cloud app get command for the Nextmv CLI. +""" + +import json +import os +from typing import Annotated + +import typer + +from nextmv.cli.message import in_progress, print_json, success, warning +from nextmv.cli.options import AppIDOption +from nextmv.local.registry import read_local_registry + +# Set up subcommand application. +app = typer.Typer() + + +@app.command() +def get( + app_id: AppIDOption, + output: Annotated[ + str | None, + typer.Option( + "--output", + "-o", + help="Saves the app information to this location.", + metavar="OUTPUT_PATH", + ), + ] = None, +) -> None: + """ + Get a local Nextmv application. + + This command is useful to get the attributes of an existing local Nextmv + application by its ID. + + [bold][underline]Examples[/underline][/bold] + + - Get the application with the ID [magenta]hare-app[/magenta]. + $ [dim]nextmv local app get --app-id hare-app[/dim] + + - Get the application with the ID [magenta]hare-app[/magenta] and save the information to an + [magenta]app.json[/magenta] file. + $ [dim]nextmv local app get --app-id hare-app --output app.json[/dim] + """ + + registry = read_local_registry() + in_progress(msg="Getting application...") + + app_entry = next((app for app in registry.apps if app.app_id == app_id), None) + + if app_entry is None: + typer.echo(f"Application with ID '{app_id}' not found.") + raise typer.Exit(code=1) + elif not os.path.exists(app_entry.path): + warning(f"Application with ID '{app_id}' found in registry but path does not exist.") + + app_dict = app_entry.to_dict() + + if output is not None and output != "": + with open(output, "w") as f: + json.dump(app_dict, f, indent=2) + success(msg=f"Application information saved to [magenta]{output}[/magenta].") + + return + + print_json(app_dict) diff --git a/nextmv/nextmv/cli/local/app/list.py b/nextmv/nextmv/cli/local/app/list.py new file mode 100644 index 00000000..84235ae8 --- /dev/null +++ b/nextmv/nextmv/cli/local/app/list.py @@ -0,0 +1,52 @@ +""" +This module defines the cloud app list command for the Nextmv CLI. +""" + +import json +from typing import Annotated + +import typer + +from nextmv.cli.message import print_json, success +from nextmv.local.registry import read_local_registry + +# Set up subcommand application. +app = typer.Typer() + + +@app.command() +def list( + output: Annotated[ + str | None, + typer.Option( + "--output", + "-o", + help="Saves the app list information to this location.", + metavar="OUTPUT_PATH", + ), + ] = None, +) -> None: + """ + List all local Nextmv applications. + + [bold][underline]Examples[/underline][/bold] + + - List all applications. + $ [dim]nextmv local app list[/dim] + + - List all applications and save the information to an [magenta]apps.json[/magenta] file. + $ [dim]nextmv local app list --output apps.json[/dim] + """ + + registry = read_local_registry() + apps_dicts = [app.to_dict() for app in registry.apps] + + if output is not None and output != "": + with open(output, "w") as f: + json.dump(apps_dicts, f, indent=2) + + success(msg=f"Application list information saved to [magenta]{output}[/magenta].") + + return + + print_json(apps_dicts) diff --git a/nextmv/nextmv/cli/local/app/sync.py b/nextmv/nextmv/cli/local/app/sync.py new file mode 100644 index 00000000..083cf0ae --- /dev/null +++ b/nextmv/nextmv/cli/local/app/sync.py @@ -0,0 +1,40 @@ +""" +This module defines the cloud app push command for the Nextmv CLI. +""" + +from typing import Annotated + +import typer + +from nextmv.cli.options import ProfileOption + +# Set up subcommand application. +app = typer.Typer() + + +@app.command() +def sync( + output: Annotated[ + str | None, + typer.Option( + "--output", + "-o", + help="Saves the app list information to this location.", + metavar="OUTPUT_PATH", + ), + ] = None, + profile: ProfileOption = None, +) -> None: + """ + Sync local Nextmv applications to the Nextmv Cloud. + + [bold][underline]Examples[/underline][/bold] + + - Sync local applications to the Nextmv Cloud. + $ [dim]nextmv local app sync[/dim] + + - Sync local applications using the profile named [magenta]hare[/magenta]. + $ [dim]nextmv local app sync --profile hare[/dim] + """ + + # TODO: replace copied code with actual logic / connect to actual logic diff --git a/nextmv/nextmv/cli/local/run/__init__.py b/nextmv/nextmv/cli/local/run/__init__.py new file mode 100644 index 00000000..c1467c1f --- /dev/null +++ b/nextmv/nextmv/cli/local/run/__init__.py @@ -0,0 +1,37 @@ +""" +This module defines the local run command tree for the Nextmv CLI. +""" + +import typer + +from nextmv.cli.local.run.cancel import app as cancel_app +from nextmv.cli.local.run.create import app as create_app +from nextmv.cli.local.run.get import app as get_app +from nextmv.cli.local.run.input import app as input_app +from nextmv.cli.local.run.list import app as list_app +from nextmv.cli.local.run.logs import app as logs_app +from nextmv.cli.local.run.metadata import app as metadata_app +from nextmv.cli.local.run.visuals import app as visuals_app + +# Set up subcommand application. +app = typer.Typer() +app.add_typer(cancel_app) +app.add_typer(create_app) +app.add_typer(get_app) +app.add_typer(input_app) +app.add_typer(list_app) +app.add_typer(logs_app) +app.add_typer(metadata_app) +app.add_typer(visuals_app) + + +@app.callback() +def callback() -> None: + """ + Create and manage Nextmv Local application runs. + + A run represents the execution of a decision model within a Nextmv Local + application. Each run takes an input, processes it using the decision model, + and produces an output. + """ + pass diff --git a/nextmv/nextmv/cli/local/run/cancel.py b/nextmv/nextmv/cli/local/run/cancel.py new file mode 100644 index 00000000..2cbce087 --- /dev/null +++ b/nextmv/nextmv/cli/local/run/cancel.py @@ -0,0 +1,31 @@ +""" +This module defines the local run cancel command for the Nextmv CLI. +""" + +import typer + +from nextmv.cli.options import AppIDOption, RunIDOption + +# Set up subcommand application. +app = typer.Typer() + + +@app.command() +def cancel( + app_id: AppIDOption, + run_id: RunIDOption, +) -> None: + """ + Cancel a queued/running Nextmv Local application run. + + [bold][underline]Examples[/underline][/bold] + + - Cancel the run with ID [magenta]burrow-123[/magenta] belonging to an app with ID [magenta]hare-app[/magenta]. + $ [dim]nextmv local run cancel --app-id hare-app --run-id burrow-123[/dim] + + - Cancel the run with ID [magenta]burrow-123[/magenta] belonging to an app with ID [magenta]hare-app[/magenta]. + Use the profile named [magenta]hare[/magenta]. + $ [dim]nextmv local run cancel --app-id hare-app --run-id burrow-123 --profile hare[/dim] + """ + + # TODO: replace copied code with actual logic / connect to actual logic diff --git a/nextmv/nextmv/cli/local/run/create.py b/nextmv/nextmv/cli/local/run/create.py new file mode 100644 index 00000000..e68d3775 --- /dev/null +++ b/nextmv/nextmv/cli/local/run/create.py @@ -0,0 +1,292 @@ +""" +This module defines the cloud run create command for the Nextmv CLI. +""" + +from typing import Annotated + +import typer + +from nextmv.cli.message import enum_values +from nextmv.cli.options import AppIDOption +from nextmv.input import InputFormat +from nextmv.run import RunType + +# Set up subcommand application. +app = typer.Typer() + + +@app.command() +def create( + app_id: AppIDOption, + # Options for controlling input. + input: Annotated[ + str | None, + typer.Option( + "--input", + "-i", + help="The input path to use. File or directory depending on content format. " + "Uses [magenta]stdin[/magenta] if not defined. " + "Can be a [magenta].tar.gz[/magenta] file for multi-file content format.", + metavar="INPUT_PATH", + rich_help_panel="Input control", + ), + ] = None, + # Options for controlling output. + logs: Annotated[ + str | None, + typer.Option( + "--logs", + "-l", + help="Waits for the run to complete and saves the logs to this location.", + metavar="LOGS_PATH", + rich_help_panel="Output control", + ), + ] = None, + output: Annotated[ + str | None, + typer.Option( + "--output", + "-u", + help="Waits for the run to complete and save the output to this location. " + "A file or directory will be created depending on content format. ", + metavar="OUTPUT_PATH", + rich_help_panel="Output control", + ), + ] = None, + tail: Annotated[ + bool, + typer.Option( + "--tail", + "-t", + help="Tail the logs until the run completes. Logs are streamed to [magenta]stderr[/magenta]. " + "Specify log output location with --logs.", + rich_help_panel="Output control", + ), + ] = False, + wait: Annotated[ + bool, + typer.Option( + "--wait", + "-w", + help="Wait for the run to complete. Run result is printed to [magenta]stdout[/magenta] for " + "[magenta]json[/magenta], to a dir for [magenta]multi-file[/magenta]. " + "Specify output location with --output.", + rich_help_panel="Output control", + ), + ] = False, + # Options for run configuration. + content_format: Annotated[ + InputFormat | None, + typer.Option( + "--content-format", + "-c", + help=f"The content format of the run to create. Allowed values are: {enum_values(InputFormat)}.", + metavar="CONTENT_FORMAT", + rich_help_panel="Run configuration", + ), + ] = None, + definition_id: Annotated[ + str | None, + typer.Option( + "--definition-id", + "-d", + help="The definition ID to use for the run. Required for certain run types like ensemble runs.", + metavar="DEFINITION_ID", + rich_help_panel="Run configuration", + ), + ] = None, + description: Annotated[ + str | None, + typer.Option( + help="An optional description for the new run.", + metavar="DESCRIPTION", + rich_help_panel="Run configuration", + ), + ] = None, + execution_class: Annotated[ + str | None, + typer.Option( + "--execution-class", + "-e", + help="The execution class to use for the run, if applicable.", + metavar="EXECUTION_CLASS", + rich_help_panel="Run configuration", + ), + ] = None, + instance_id: Annotated[ + str | None, + typer.Option( + help="The instance ID to use for the run.", + metavar="INSTANCE_ID", + rich_help_panel="Run configuration", + ), + ] = "latest", + integration_id: Annotated[ + str | None, + typer.Option( + help="The integration ID to use for the run, if applicable.", + metavar="INTEGRATION_ID", + rich_help_panel="Run configuration", + ), + ] = None, + name: Annotated[ + str | None, + typer.Option( + "--name", + "-n", + help="An optional name for the new run.", + metavar="NAME", + rich_help_panel="Run configuration", + ), + ] = None, + no_queuing: Annotated[ + bool, + typer.Option( + "--no-queuing", + help="Do not queue run. Default is [magenta]False[/magenta], " + "meaning the run [italic]will[/italic] be queued.", + rich_help_panel="Run configuration", + ), + ] = False, + options: Annotated[ + list[str] | None, + typer.Option( + "--options", + "-o", + help="Options passed to the run. Format: [magenta]key=value[/magenta]. " + "Pass multiple options by repeating the flag, or separating with commas.", + metavar="KEY=VALUE", + rich_help_panel="Run configuration", + ), + ] = None, + priority: Annotated[ + int, + typer.Option( + help="The priority of the run. Priority is between 1 and 10, with 1 being the highest priority.", + metavar="PRIORITY", + rich_help_panel="Run configuration", + ), + ] = 6, + run_type: Annotated[ + RunType, + typer.Option( + "--run-type", + "-r", + help=f"The type of run to create. Allowed values are: {enum_values(RunType)}.", + metavar="RUN_TYPE", + rich_help_panel="Run configuration", + ), + ] = RunType.STANDARD, + secret_collection_id: Annotated[ + str | None, + typer.Option( + "--secret-collection-id", + "-s", + help="The secret collection ID to use for the run, if applicable.", + metavar="SECRET_COLLECTION_ID", + rich_help_panel="Run configuration", + ), + ] = None, + timeout: Annotated[ + int, + typer.Option( + help="The maximum time in seconds to wait for results when polling. Poll indefinitely if not set.", + metavar="TIMEOUT_SECONDS", + rich_help_panel="Run configuration", + ), + ] = -1, +) -> None: + """ + Create a new Nextmv Local application run. + + Input for the run should be given through [magenta]stdin[/magenta] or the + --input flag. When using the --input flag, the value can be one of the + following: + + - [yellow][/yellow]: path to a [magenta]file[/magenta] containing + the input data. Use with the [magenta]json[/magenta], and + [magenta]text[/magenta] content formats. + - [yellow][/yellow]: path to a [magenta]directory[/magenta] + containing the input data files. Use with the + [magenta]multi-file[/magenta] content format. + - [yellow]<.tar.gz PATH>[/yellow]: path to a [magenta].tar.gz[/magenta] file + containing tarred input data files. Use with the + [magenta]multi-file[/magenta] content format. + + The CLI determines how to send the input to the application based on the + value. + + Use the --wait flag to wait for the run to complete, polling for results. + Using the --output flag will also activate waiting, and allows you to + specify a destination (file or dir) for the output, depending on the + content type. + + Use the --tail flag to stream logs to [magenta]stderr[/magenta] until the + run completes. Using the --logs flag will also activate waiting, and allows + you to specify a file to write the logs to. + + An application run executes against a specific instance. An instance + represents the combination of executable code and configuration. You can + specify the instance with the --instance-id flag. These are the possible + values for this flag: + + - [yellow]latest[/yellow]: uses the special [magenta]latest[/magenta] + instance of the application. This corresponds to the latest pushed + executable. This is the default behavior. + - [yellow]default[/yellow]: if the application has a [italic]default[/italic] + instance configured, then it uses that instance. Setting the flag's value + to [magenta]''[/magenta] (empty string) has the same effect. + - [yellow][/yellow]: uses the instance with the given ID. + + [bold][underline]Examples[/underline][/bold] + + - Read a [magenta]json[/magenta] input via [magenta]stdin[/magenta], from an [magenta]input.json[/magenta] file, + and submit a run to an app with ID [magenta]hare-app[/magenta], using the [magenta]latest[/magenta] instance. + $ [dim]cat input.json | nextmv local run create --app-id hare-app[/dim] + + - Read a [magenta]json[/magenta] input from an [magenta]input.json[/magenta] file, and + submit a run to an app with ID [magenta]hare-app[/magenta], using the [magenta]latest[/magenta] instance. + $ [dim]nextmv local run create --app-id hare-app --input input.json[/dim] + - Read a [magenta]json[/magenta] input from an [magenta]input.json[/magenta] file, and + submit a run to an app with ID [magenta]hare-app[/magenta], using the [magenta]latest[/magenta] instance. + Wait for the run to complete and print the result to [magenta]stdout[/magenta]. + $ [dim]nextmv local run create --app-id hare-app --input input.json --wait[/dim] + + - Read a [magenta]json[/magenta] input from an [magenta]input.json[/magenta] file, and + submit a run to an app with ID [magenta]hare-app[/magenta], using the [magenta]latest[/magenta] instance. + Tail the run's logs, streaming to [magenta]stderr[/magenta]. + $ [dim]nextmv local run create --app-id hare-app --input input.json --tail[/dim] + + - Read a [magenta]json[/magenta] input from an [magenta]input.json[/magenta] file, and + submit a run to an app with ID [magenta]hare-app[/magenta], using the [magenta]latest[/magenta] instance. + Wait for the run to complete and write the result to an [magenta]output.json[/magenta] file. + $ [dim]nextmv local run create --app-id hare-app --input input.json --output output.json[/dim] + + - Read a [magenta]json[/magenta] input from an [magenta]input.json[/magenta] file, and + submit a run to an app with ID [magenta]hare-app[/magenta], using the [magenta]latest[/magenta] instance. + Wait for the run to complete, and write the logs to a [magenta]logs.log[/magenta] file. + $ [dim]nextmv local run create --app-id hare-app --input input.json --logs logs.log[/dim] + + - Read a [magenta]json[/magenta] input from an [magenta]input.json[/magenta] file, and submit a run to an app with + ID [magenta]hare-app[/magenta], using the [magenta]latest[/magenta] instance. Wait for the run to complete. Tail + the run's logs, streaming to [magenta]stderr[/magenta]. Write the logs to a [magenta]logs.log[/magenta] file. + Write the result to an [magenta]output.json[/magenta] file. + $ [dim]nextmv local run create --app-id hare-app --input input.json --tail --logs logs.log \\ + --output output.json [/dim] + + - Read a [magenta]multi-file[/magenta] input from an [magenta]inputs[/magenta] directory, and + submit a run to an app with ID [magenta]hare-app[/magenta], using the [magenta]default[/magenta] instance. + $ [dim]nextmv local run create --app-id hare-app --input inputs --instance-id default[/dim] + + - Read a [magenta]multi-file[/magenta] input from an [magenta]inputs[/magenta] directory, and + submit a run to an app with ID [magenta]hare-app[/magenta], using the [magenta]default[/magenta] instance. + Wait for the run to complete, and save the results to the default location (a directory named after the run ID). + $ [dim]nextmv local run create --app-id hare-app --input inputs --instance-id default --wait[/dim] + + - Read a [magenta]multi-file[/magenta] input from an [magenta]inputs[/magenta] directory, and + submit a run to an app with ID [magenta]hare-app[/magenta], using the [magenta]burrow[/magenta] instance. + Wait for the run to complete and download the result files to an [magenta]outputs[/magenta] directory. + $ [dim]nextmv local run create --app-id hare-app --input inputs --instance-id burrow --output outputs[/dim] + """ + + # TODO: replace copied code with actual logic / connect to actual logic diff --git a/nextmv/nextmv/cli/local/run/get.py b/nextmv/nextmv/cli/local/run/get.py new file mode 100644 index 00000000..75582e7b --- /dev/null +++ b/nextmv/nextmv/cli/local/run/get.py @@ -0,0 +1,80 @@ +""" +This module defines the cloud run get command for the Nextmv CLI. +""" + +from typing import Annotated + +import typer + +from nextmv.cli.options import AppIDOption, RunIDOption + +# Set up subcommand application. +app = typer.Typer() + + +@app.command() +def get( + app_id: AppIDOption, + run_id: RunIDOption, + output: Annotated[ + str | None, + typer.Option( + "--output", + "-o", + help="Waits for the run to complete and save the output to this location. " + "A file or directory will be created depending on content format.", + metavar="OUTPUT_PATH", + ), + ] = None, + timeout: Annotated[ + int, + typer.Option( + help="The maximum time in seconds to wait for results when polling. Poll indefinitely if not set.", + metavar="TIMEOUT_SECONDS", + ), + ] = -1, + wait: Annotated[ + bool, + typer.Option( + "--wait", + "-w", + help="Wait for the run to complete. Run result is printed to [magenta]stdout[/magenta] for " + "[magenta]json[/magenta], to a dir for [magenta]multi-file[/magenta]. " + "Specify output location with --output.", + ), + ] = False, +) -> None: + """ + Get the result (output) of a Nextmv Cloud application run. + + Use the --wait flag to wait for the run to complete, polling + for results. Using the --output flag will also activate + waiting, and allows you to specify a destination (file or dir) for the + output, depending on the content type. + + [bold][underline]Examples[/underline][/bold] + + - Get the results of a run with ID [magenta]burrow-123[/magenta], belonging to an app with ID + [magenta]hare-app[/magenta]. + $ [dim]nextmv cloud run get --app-id hare-app --run-id burrow-123[/dim] + + - Get the results of a run with ID [magenta]burrow-123[/magenta], belonging to an app with ID + [magenta]hare-app[/magenta]. Wait for the run to complete if necessary. + $ [dim]nextmv cloud run get --app-id hare-app --run-id burrow-123 --wait[/dim] + + - Get the results of a run with ID [magenta]burrow-123[/magenta], belonging to an app with ID + [magenta]hare-app[/magenta]. The app is a [magenta]json[/magenta] app. + Save the results to a [magenta]results.json[/magenta] file. + $ [dim]nextmv cloud run get --app-id hare-app --run-id burrow-123 --output results.json[/dim] + + - Get the results of a run with ID [magenta]burrow-123[/magenta], belonging to an app with ID + [magenta]hare-app[/magenta]. The app is a [magenta]multi-file[/magenta] app. + Save the results to the [magenta]results[/magenta] dir. + $ [dim]nextmv cloud run get --app-id hare-app --run-id burrow-123 --output results[/dim] + + - Get the results of a run with ID [magenta]burrow-123[/magenta], belonging to an app with ID + [magenta]hare-app[/magenta]. Use the profile named [magenta]hare[/magenta]. + $ [dim]nextmv cloud run get --app-id hare-app --run-id burrow-123 --profile hare[/dim] + """ + + # TODO: replace copied code with actual logic / connect to actual logic diff --git a/nextmv/nextmv/cli/local/run/input.py b/nextmv/nextmv/cli/local/run/input.py new file mode 100644 index 00000000..2ff984fa --- /dev/null +++ b/nextmv/nextmv/cli/local/run/input.py @@ -0,0 +1,50 @@ +""" +This module defines the cloud run input command for the Nextmv CLI. +""" + +from typing import Annotated + +import typer + +from nextmv.cli.options import AppIDOption, RunIDOption + +# Set up subcommand application. +app = typer.Typer() + + +@app.command() +def input( + app_id: AppIDOption, + run_id: RunIDOption, + output: Annotated[ + str | None, + typer.Option( + "--output", + "-o", + help="Saves the input to this location.", + metavar="OUTPUT_PATH", + ), + ] = None, +) -> None: + """ + Get the input of a Nextmv Cloud application run. + + By default, the input is fetched and printed to [magenta]stdout[/magenta]. + Use the --output flag to save the input to a file. + + [bold][underline]Examples[/underline][/bold] + + - Get the input of a run with ID [magenta]burrow-123[/magenta], belonging to an app with ID + [magenta]hare-app[/magenta]. Input is printed to [magenta]stdout[/magenta]. + $ [dim]nextmv cloud run input --app-id hare-app --run-id burrow-123[/dim] + + - Get the input of a run with ID [magenta]burrow-123[/magenta], belonging to an app with ID + [magenta]hare-app[/magenta]. Save the input to a [magenta]input.json[/magenta] file. + $ [dim]nextmv cloud run input --app-id hare-app --run-id burrow-123 --output input.json[/dim] + + - Get the input of a run with ID [magenta]burrow-123[/magenta], belonging to an app with ID + [magenta]hare-app[/magenta]. Use the profile named [magenta]hare[/magenta]. + $ [dim]nextmv cloud run input --app-id hare-app --run-id burrow-123 --profile hare[/dim] + """ + + # TODO: replace copied code with actual logic / connect to actual logic diff --git a/nextmv/nextmv/cli/local/run/list.py b/nextmv/nextmv/cli/local/run/list.py new file mode 100644 index 00000000..b3c8a173 --- /dev/null +++ b/nextmv/nextmv/cli/local/run/list.py @@ -0,0 +1,64 @@ +""" +This module defines the cloud run list command for the Nextmv CLI. +""" + +from typing import Annotated + +import typer + +from nextmv.cli.message import enum_values +from nextmv.cli.options import AppIDOption +from nextmv.status import StatusV2 + +# Set up subcommand application. +app = typer.Typer() + + +@app.command() +def list( + app_id: AppIDOption, + output: Annotated[ + str | None, + typer.Option( + "--output", + "-o", + help="Saves the list of runs to this location.", + metavar="OUTPUT_PATH", + ), + ] = None, + status: Annotated[ + StatusV2 | None, + typer.Option( + "--status", + "-s", + help=f"Filter runs by their status. Allowed values are: {enum_values(StatusV2)}.", + metavar="STATUS", + ), + ] = None, +) -> None: + """ + Get the list of runs for a Nextmv Cloud application. + + By default, the list of runs is fetched and printed to [magenta]stdout[/magenta]. + Use the --output flag to save the list to a file. + + You can use the optional --status flag to filter runs by their status. + + [bold][underline]Examples[/underline][/bold] + + - Get the list of runs for an app with ID [magenta]hare-app[/magenta]. List is printed to [magenta]stdout[/magenta]. + $ [dim]nextmv cloud run list --app-id hare-app[/dim] + + - Get the list of runs for an app with ID [magenta]hare-app[/magenta]. Save the list to a + [magenta]runs.json[/magenta] file. + $ [dim]nextmv cloud run list --app-id hare-app --output runs.json[/dim] + + - Get the list of runs for an app with ID [magenta]hare-app[/magenta]. + Use the profile named [magenta]hare[/magenta]. + $ [dim]nextmv cloud run list --app-id hare-app --profile hare[/dim] + + - Get the list of [magenta]queued[/magenta] runs for an app with ID [magenta]hare-app[/magenta]. + $ [dim]nextmv cloud run list --app-id hare-app --status queued[/dim] + """ + + # TODO: replace copied code with actual logic / connect to actual logic diff --git a/nextmv/nextmv/cli/local/run/logs.py b/nextmv/nextmv/cli/local/run/logs.py new file mode 100644 index 00000000..f815a5da --- /dev/null +++ b/nextmv/nextmv/cli/local/run/logs.py @@ -0,0 +1,76 @@ +""" +This module defines the cloud run logs command for the Nextmv CLI. +""" + +from typing import Annotated + +import typer + +from nextmv.cli.options import AppIDOption, RunIDOption + +# Set up subcommand application. +app = typer.Typer() + + +@app.command() +def logs( + app_id: AppIDOption, + run_id: RunIDOption, + output: Annotated[ + str | None, + typer.Option( + "--output", + "-o", + help="Waits for the run to complete and saves the logs to this location.", + metavar="OUTPUT_PATH", + ), + ] = None, + tail: Annotated[ + bool, + typer.Option( + "--tail", + "-t", + help="Tail the logs until the run completes. Logs are streamed to [magenta]stderr[/magenta]. " + "Specify log output location with --output.", + ), + ] = False, + timeout: Annotated[ + int, + typer.Option( + help="The maximum time in seconds to wait for results when polling. Poll indefinitely if not set.", + metavar="TIMEOUT_SECONDS", + ), + ] = -1, +) -> None: + """ + Get the logs of a Nextmv Cloud application run. + + By default, the logs are fetched and printed to [magenta]stderr[/magenta]. + Use the --tail flag to stream logs to [magenta]stderr[/magenta] until the + run completes. Using the --output flag will also activate waiting, and + allows you to specify a file to write the logs to. + + [bold][underline]Examples[/underline][/bold] + + - Get the logs of a run with ID [magenta]burrow-123[/magenta], belonging to an app with ID + [magenta]hare-app[/magenta]. Logs are printed to [magenta]stderr[/magenta]. + $ [dim]nextmv cloud run logs --app-id hare-app --run-id burrow-123[/dim] + + - Get the logs of a run with ID [magenta]burrow-123[/magenta], belonging to an app with ID + [magenta]hare-app[/magenta]. Tail the logs until the run completes. + $ [dim]nextmv cloud run logs --app-id hare-app --run-id burrow-123 --tail[/dim] + + - Get the logs of a run with ID [magenta]burrow-123[/magenta], belonging to an app with ID + [magenta]hare-app[/magenta]. Save the logs to a [magenta]logs.log[/magenta] file. + $ [dim]nextmv cloud run logs --app-id hare-app --run-id burrow-123 --output logs.log[/dim] + + - Get the logs of a run with ID [magenta]burrow-123[/magenta], belonging to an app with ID + [magenta]hare-app[/magenta]. Tail the logs and save them to a [magenta]logs.log[/magenta] file. + $ [dim]nextmv cloud run logs --app-id hare-app --run-id burrow-123 --tail --output logs.log[/dim] + + - Get the logs of a run with ID [magenta]burrow-123[/magenta], belonging to an app with ID + [magenta]hare-app[/magenta]. Use the profile named [magenta]hare[/magenta]. + $ [dim]nextmv cloud run logs --app-id hare-app --run-id burrow-123 --profile hare[/dim] + """ + + # TODO: replace copied code with actual logic / connect to actual logic diff --git a/nextmv/nextmv/cli/local/run/metadata.py b/nextmv/nextmv/cli/local/run/metadata.py new file mode 100644 index 00000000..66a84b37 --- /dev/null +++ b/nextmv/nextmv/cli/local/run/metadata.py @@ -0,0 +1,50 @@ +""" +This module defines the cloud run metadata command for the Nextmv CLI. +""" + +from typing import Annotated + +import typer + +from nextmv.cli.options import AppIDOption, RunIDOption + +# Set up subcommand application. +app = typer.Typer() + + +@app.command() +def metadata( + app_id: AppIDOption, + run_id: RunIDOption, + output: Annotated[ + str | None, + typer.Option( + "--output", + "-o", + help="Saves the metadata to this location.", + metavar="OUTPUT_PATH", + ), + ] = None, +) -> None: + """ + Get the metadata of a Nextmv Cloud application run. + + By default, the metadata is fetched and printed to [magenta]stdout[/magenta]. + Use the --output flag to save the metadata to a file. + + [bold][underline]Examples[/underline][/bold] + + - Get the metadata of a run with ID [magenta]burrow-123[/magenta], belonging to an app with ID + [magenta]hare-app[/magenta]. Metadata is printed to [magenta]stdout[/magenta]. + $ [dim]nextmv cloud run metadata --app-id hare-app --run-id burrow-123[/dim] + + - Get the metadata of a run with ID [magenta]burrow-123[/magenta], belonging to an app with ID + [magenta]hare-app[/magenta]. Save the metadata to a [magenta]metadata.json[/magenta] file. + $ [dim]nextmv cloud run metadata --app-id hare-app --run-id burrow-123 --output metadata.json[/dim] + + - Get the metadata of a run with ID [magenta]burrow-123[/magenta], belonging to an app with ID + [magenta]hare-app[/magenta]. Use the profile named [magenta]hare[/magenta]. + $ [dim]nextmv cloud run metadata --app-id hare-app --run-id burrow-123 --profile hare[/dim] + """ + + # TODO: replace copied code with actual logic / connect to actual logic diff --git a/nextmv/nextmv/cli/local/run/visuals.py b/nextmv/nextmv/cli/local/run/visuals.py new file mode 100644 index 00000000..66a84b37 --- /dev/null +++ b/nextmv/nextmv/cli/local/run/visuals.py @@ -0,0 +1,50 @@ +""" +This module defines the cloud run metadata command for the Nextmv CLI. +""" + +from typing import Annotated + +import typer + +from nextmv.cli.options import AppIDOption, RunIDOption + +# Set up subcommand application. +app = typer.Typer() + + +@app.command() +def metadata( + app_id: AppIDOption, + run_id: RunIDOption, + output: Annotated[ + str | None, + typer.Option( + "--output", + "-o", + help="Saves the metadata to this location.", + metavar="OUTPUT_PATH", + ), + ] = None, +) -> None: + """ + Get the metadata of a Nextmv Cloud application run. + + By default, the metadata is fetched and printed to [magenta]stdout[/magenta]. + Use the --output flag to save the metadata to a file. + + [bold][underline]Examples[/underline][/bold] + + - Get the metadata of a run with ID [magenta]burrow-123[/magenta], belonging to an app with ID + [magenta]hare-app[/magenta]. Metadata is printed to [magenta]stdout[/magenta]. + $ [dim]nextmv cloud run metadata --app-id hare-app --run-id burrow-123[/dim] + + - Get the metadata of a run with ID [magenta]burrow-123[/magenta], belonging to an app with ID + [magenta]hare-app[/magenta]. Save the metadata to a [magenta]metadata.json[/magenta] file. + $ [dim]nextmv cloud run metadata --app-id hare-app --run-id burrow-123 --output metadata.json[/dim] + + - Get the metadata of a run with ID [magenta]burrow-123[/magenta], belonging to an app with ID + [magenta]hare-app[/magenta]. Use the profile named [magenta]hare[/magenta]. + $ [dim]nextmv cloud run metadata --app-id hare-app --run-id burrow-123 --profile hare[/dim] + """ + + # TODO: replace copied code with actual logic / connect to actual logic diff --git a/nextmv/nextmv/cli/main.py b/nextmv/nextmv/cli/main.py index d48e51d1..78eae717 100644 --- a/nextmv/nextmv/cli/main.py +++ b/nextmv/nextmv/cli/main.py @@ -25,6 +25,7 @@ from nextmv.cli.configuration import app as configuration_app from nextmv.cli.configuration.config import CONFIG_DIR, GO_CLI_PATH, load_config from nextmv.cli.confirm import get_confirmation +from nextmv.cli.local import app as local_app from nextmv.cli.message import error, info, success, warning from nextmv.cli.version import app as version_app from nextmv.cli.version import version_callback @@ -48,6 +49,7 @@ app.add_typer(cloud_app, name="cloud") app.add_typer(community_app, name="community") app.add_typer(configuration_app, name="configuration") +app.add_typer(local_app, name="local") app.add_typer(version_app) @@ -177,3 +179,7 @@ def main() -> None: rich.print(f"[red]Error:[/red] {msg}", file=sys.stderr) sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/nextmv/nextmv/local/application.py b/nextmv/nextmv/local/application.py index 35e0b56d..cfc99d70 100644 --- a/nextmv/nextmv/local/application.py +++ b/nextmv/nextmv/local/application.py @@ -128,6 +128,24 @@ def __post_init__(self): return + @classmethod + def from_path(cls, src: str) -> "Application": + """ + Load an application from a given source path. + + Parameters + ---------- + src : str + Source path of the application. + + Returns + ------- + Application + The loaded application instance. + """ + + return cls(src=src) + @classmethod def initialize( cls, @@ -192,6 +210,40 @@ def initialize( description=description, ) + def delete(self) -> None: + """ + Delete the local application. + + This method deletes the application by removing its source directory + from the local file system. Use with caution, as this action is + irreversible. + + Raises + ------ + FileNotFoundError + If the application's source directory does not exist. + """ + + if not os.path.exists(self.src): + raise FileNotFoundError(f"Application source directory does not exist: {self.src}") + + shutil.rmtree(self.src) + + def exists(self) -> bool: + """ + Check if the local application exists. + + This method checks if the application's source directory exists in the + local file system. + + Returns + ------- + bool + True if the application exists, False otherwise. + """ + + return os.path.exists(self.src) + def list_runs(self) -> list[Run]: """ List all runs for the application. diff --git a/nextmv/nextmv/local/local.py b/nextmv/nextmv/local/local.py index 8437753b..03419058 100644 --- a/nextmv/nextmv/local/local.py +++ b/nextmv/nextmv/local/local.py @@ -57,6 +57,11 @@ """ Constant for the default input JSON file name. """ +REGISTRY_FILE = "registry.yaml" +""" +Constant for the local registry file name. This file stores information about the apps on +the local machine. It is located in the Nextmv directory in the user's home directory. +""" def calculate_files_size(run_dir: str, run_id: str, dir_path: str, metadata_key: str) -> None: diff --git a/nextmv/nextmv/local/registry.py b/nextmv/nextmv/local/registry.py new file mode 100644 index 00000000..ce5d4f34 --- /dev/null +++ b/nextmv/nextmv/local/registry.py @@ -0,0 +1,219 @@ +""" +Local app registry for app interactions. + +This module provides functionality for interacting with the local registry of Nextmv +applications. The registry allows users to manage applications they have created locally +on their machine. + +Classes +------- +LocalRegistry + A class to interact with the local Nextmv application registry. + +Functions +--------- +read_local_registry + Retrieve an instance of the LocalRegistry. +add_registry_entry + Store a new entry in the local app registry. +delete_registry_entry + Remove an entry from the local app registry. +create_app_from_registry_entry + Create a Nextmv application instance from a registry entry. +""" + +import os +import pathlib + +import yaml + +from nextmv.base_model import BaseModel +from nextmv.local.local import NEXTMV_DIR, REGISTRY_FILE + + +class AppEntry(BaseModel): + """ + Represents an entry in the local app registry. + + You can import the `RegistryEntry` class directly from `local`: + + ```python + from nextmv.local import RegistryEntry + ``` + + This class contains information about a Nextmv application on the local machine. + + Attributes + ---------- + app_id : str + The unique identifier of the application. + path : str + The file system path where the application is located. + """ + + app_id: str + """ + The unique identifier of the application. + """ + path: str + """ + The file system path where the application is located. + """ + + +class Registry(BaseModel): + """ + Represents the local app registry. + + You can import the `Registry` class directly from `local`: + + ```python + from nextmv.local import Registry + ``` + + This class contains a list of local Nextmv applications registered on the machine. + + Attributes + ---------- + apps : list[RegistryEntry] + A list of locally registered Nextmv applications. + """ + + apps: list[AppEntry] + """ + A list of locally registered Nextmv applications. + """ + + @classmethod + def from_yaml(cls) -> "Registry": + """ + Load a Registry from a YAML file. + + The YAML file is expected to be located at `$HOME/.nextmv/registry.yaml`. + + Returns + ------- + Registry + The loaded registry. + + Raises + ------ + FileNotFoundError + If the `registry.yaml` file is not found in the expected location. + yaml.YAMLError + If there is an error parsing the YAML file. + + Examples + -------- + Assuming an `registry.yaml` file exists in `$HOME/.nextmv/` with the following content: + + ```yaml + apps: + - app_id: "app-123" + ``` + + >>> from nextmv import Registry + >>> # registry = Registry.from_yaml() # This would load the registry from the YAML file + >>> # assert isinstance(registry, Registry) + """ + reg_path = get_registry_path() + + # If no registry file exists yet, create an empty registry. + if not os.path.exists(reg_path): + empty_registry = cls(apps=[]) + empty_registry.to_yaml() + return empty_registry + + # Load and parse the YAML file. + with open(reg_path) as file: + raw_manifest = yaml.safe_load(file) + return cls.from_dict(raw_manifest) + + def to_yaml(self) -> None: + """ + Write the registry to a YAML file. + + The registry will be written to `$HOME/.nextmv/registry.yaml`. + + Raises + ------ + IOError + If there is an error writing the file. + yaml.YAMLError + If there is an error serializing the registry to YAML. + + Examples + -------- + >>> from nextmv import Registry + >>> registry = Registry(apps=[]) + >>> # registry.to_yaml() # This would write the registry to the YAML file + """ + + with open(get_registry_path(), "w") as file: + yaml.dump( + self.to_dict(), + file, + sort_keys=False, + default_flow_style=False, + indent=2, + width=120, + ) + + +def get_registry_path() -> str: + """ + Returns the path to the local registry file. + + Returns + ------- + str + The path to the local registry file. + """ + home_dir = str(pathlib.Path.home()) + nextmv_dir = os.path.join(home_dir, NEXTMV_DIR) + os.makedirs(nextmv_dir, exist_ok=True) + registry_path = os.path.join(nextmv_dir, REGISTRY_FILE) + return registry_path + + +def read_local_registry() -> Registry: + """ + Retrieve an instance of the LocalRegistry. + + Returns + ------- + Registry + The local app registry. + """ + return Registry.from_yaml() + + +def add_registry_entry(entry: AppEntry) -> None: + """ + Store a new entry in the local app registry. + + Parameters + ---------- + entry : RegistryEntry + The registry entry to add. + """ + registry = Registry.from_yaml() + if any(app.app_id == entry.app_id for app in registry.apps): + # Ignore duplicate entries. + return + registry.apps.append(entry) + registry.to_yaml() + + +def delete_registry_entry(app_id: str) -> None: + """ + Remove an entry from the local app registry. + + Parameters + ---------- + app_id : str + The ID of the application to remove from the registry. + """ + registry = Registry.from_yaml() + registry.apps = [entry for entry in registry.apps if entry.app_id != app_id] + registry.to_yaml()