diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 00000000..210545bc --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,742 @@ +# `garden-ai` + +🌱 Hello, Garden 🌱 + +**Usage**: + +```console +$ garden-ai [OPTIONS] COMMAND [ARGS]... +``` + +**Options**: + +* `--version` +* `--install-completion`: Install completion for the current shell. +* `--show-completion`: Show completion for the current shell, to copy it or customize the installation. +* `--help`: Show this message and exit. + +**Commands**: + +* `whoami`: Print the email of the currently logged in... +* `login`: Attempts to login if the user is currently... +* `logout`: Logs out the current user. +* `mcp`: MCP server commands +* `garden`: Manage Gardens +* `function`: Manage functions (Modal and HPC) + +## `garden-ai whoami` + +Print the email of the currently logged in user. If logged out, attempt a login. + +**Usage**: + +```console +$ garden-ai whoami [OPTIONS] +``` + +**Options**: + +* `--help`: Show this message and exit. + +## `garden-ai login` + +Attempts to login if the user is currently logged out. + +**Usage**: + +```console +$ garden-ai login [OPTIONS] +``` + +**Options**: + +* `--help`: Show this message and exit. + +## `garden-ai logout` + +Logs out the current user. + +**Usage**: + +```console +$ garden-ai logout [OPTIONS] +``` + +**Options**: + +* `--help`: Show this message and exit. + +## `garden-ai mcp` + +MCP server commands + +**Usage**: + +```console +$ garden-ai mcp [OPTIONS] COMMAND [ARGS]... +``` + +**Options**: + +* `--help`: Show this message and exit. + +**Commands**: + +* `setup`: Add config file for client +* `serve`: Start the Garden MCP server. + +### `garden-ai mcp setup` + +Add config file for client + +**Usage**: + +```console +$ garden-ai mcp setup [OPTIONS] +``` + +**Options**: + +* `--client TEXT`: 'claude', 'claude code', 'gemini', 'cursor', 'windsurf' +* `--path TEXT`: Path to initalize config file for any other mcp client +* `--help`: Show this message and exit. + +### `garden-ai mcp serve` + +Start the Garden MCP server. + +**Usage**: + +```console +$ garden-ai mcp serve [OPTIONS] +``` + +**Options**: + +* `--help`: Show this message and exit. + +## `garden-ai garden` + +Manage Gardens + +**Usage**: + +```console +$ garden-ai garden [OPTIONS] COMMAND [ARGS]... +``` + +**Options**: + +* `--help`: Show this message and exit. + +**Commands**: + +* `create`: Create a new garden. +* `list`: List gardens. +* `search`: Search for gardens using full-text search. +* `show`: Show details of a specific garden. +* `update`: Update a garden's metadata. +* `add-functions`: Add functions to an existing garden. +* `delete`: Delete a garden. + +### `garden-ai garden create` + +Create a new garden. + +**Usage**: + +```console +$ garden-ai garden create [OPTIONS] +``` + +**Options**: + +* `-t, --title TEXT`: Title of the garden [required] +* `-a, --authors TEXT`: Comma-separated list of authors. If not provided, uses the current user. +* `-c, --contributors TEXT`: Comma-separated list of contributors +* `-d, --description TEXT`: Description of the garden +* `--tags TEXT`: Comma-separated list of tags +* `-m, --modal-function-ids TEXT`: Comma-separated Modal function IDs +* `-g, --hpc-function-ids TEXT`: Comma-separated HPC function IDs +* `--year TEXT`: Publication year [default: 2026] +* `--version TEXT`: Garden version [default: 0.0.1] +* `--help`: Show this message and exit. + +### `garden-ai garden list` + +List gardens. + +**Usage**: + +```console +$ garden-ai garden list [OPTIONS] +``` + +**Options**: + +* `--all`: List all published gardens instead of just yours +* `--tags TEXT`: Filter by comma-separated tags +* `--authors TEXT`: Filter by comma-separated authors +* `--year TEXT`: Filter by year +* `-n, --limit INTEGER`: Maximum results to show [default: 20] +* `--json`: Output results as JSON +* `--pretty`: Pretty-print JSON output +* `--help`: Show this message and exit. + +### `garden-ai garden search` + +Search for gardens using full-text search. + +**Usage**: + +```console +$ garden-ai garden search [OPTIONS] QUERY +``` + +**Arguments**: + +* `QUERY`: Search query [required] + +**Options**: + +* `-n, --limit INTEGER`: Maximum results to show [default: 10] +* `--json`: Output results as JSON +* `--pretty`: Pretty-print JSON output +* `--help`: Show this message and exit. + +### `garden-ai garden show` + +Show details of a specific garden. + +**Usage**: + +```console +$ garden-ai garden show [OPTIONS] DOI +``` + +**Arguments**: + +* `DOI`: DOI of the garden to show [required] + +**Options**: + +* `--json`: Output results as JSON +* `--pretty`: Pretty-print JSON output +* `--help`: Show this message and exit. + +### `garden-ai garden update` + +Update a garden's metadata. + +Note: For list fields (authors, contributors, tags, function IDs), the provided +values will REPLACE the entire existing list. To add or remove individual items, +first retrieve the current values with 'garden show', modify as needed, then +provide the complete new list. + +**Usage**: + +```console +$ garden-ai garden update [OPTIONS] DOI +``` + +**Arguments**: + +* `DOI`: DOI of the garden to update [required] + +**Options**: + +* `-t, --title TEXT`: New title +* `-a, --authors TEXT`: Comma-separated list of authors. Replaces the entire authors list. +* `-c, --contributors TEXT`: Comma-separated list of contributors. Replaces the entire contributors list. +* `-d, --description TEXT`: New description +* `--tags TEXT`: Comma-separated list of tags. Replaces the entire tags list. +* `--version TEXT`: New version +* `-m, --modal-function-ids TEXT`: Comma-separated Modal function IDs. Replaces the entire list. +* `-g, --hpc-function-ids TEXT`: Comma-separated HPC function IDs. Replaces the entire list. +* `--help`: Show this message and exit. + +### `garden-ai garden add-functions` + +Add functions to an existing garden. + +**Usage**: + +```console +$ garden-ai garden add-functions [OPTIONS] DOI +``` + +**Arguments**: + +* `DOI`: DOI of the garden [required] + +**Options**: + +* `-m, --modal-function-ids TEXT`: Comma-separated Modal function IDs to add +* `-g, --hpc-function-ids TEXT`: Comma-separated HPC function IDs to add +* `--replace`: Replace existing functions instead of adding +* `--help`: Show this message and exit. + +### `garden-ai garden delete` + +Delete a garden. + +**Usage**: + +```console +$ garden-ai garden delete [OPTIONS] DOI +``` + +**Arguments**: + +* `DOI`: DOI of the garden to delete [required] + +**Options**: + +* `-f, --force`: Skip confirmation prompt +* `--help`: Show this message and exit. + +## `garden-ai function` + +Manage functions (Modal and HPC) + +**Usage**: + +```console +$ garden-ai function [OPTIONS] COMMAND [ARGS]... +``` + +**Options**: + +* `--help`: Show this message and exit. + +**Commands**: + +* `modal`: Manage Modal functions and apps +* `hpc`: Manage HPC functions + +### `garden-ai function modal` + +Manage Modal functions and apps + +**Usage**: + +```console +$ garden-ai function modal [OPTIONS] COMMAND [ARGS]... +``` + +**Options**: + +* `--help`: Show this message and exit. + +**Commands**: + +* `list`: List your Modal functions. +* `show`: Show details of a Modal function. +* `update`: Update a Modal function's metadata. +* `app`: Manage Modal apps + +#### `garden-ai function modal list` + +List your Modal functions. + +**Usage**: + +```console +$ garden-ai function modal list [OPTIONS] +``` + +**Options**: + +* `-n, --limit INTEGER`: Maximum results [default: 50] +* `--json`: Output results as JSON +* `--pretty`: Pretty-print JSON output +* `--help`: Show this message and exit. + +#### `garden-ai function modal show` + +Show details of a Modal function. + +**Usage**: + +```console +$ garden-ai function modal show [OPTIONS] FUNCTION_ID +``` + +**Arguments**: + +* `FUNCTION_ID`: Modal function ID [required] + +**Options**: + +* `-c, --code`: Show function code +* `--json`: Output results as JSON +* `--pretty`: Pretty-print JSON output +* `--help`: Show this message and exit. + +#### `garden-ai function modal update` + +Update a Modal function's metadata. + +**Usage**: + +```console +$ garden-ai function modal update [OPTIONS] FUNCTION_ID +``` + +**Arguments**: + +* `FUNCTION_ID`: Modal function ID [required] + +**Options**: + +* `-t, --title TEXT`: New title +* `-d, --description TEXT`: New description +* `-a, --authors TEXT`: New comma-separated authors +* `--tags TEXT`: New comma-separated tags +* `--help`: Show this message and exit. + +#### `garden-ai function modal app` + +Manage Modal apps + +**Usage**: + +```console +$ garden-ai function modal app [OPTIONS] COMMAND [ARGS]... +``` + +**Options**: + +* `--help`: Show this message and exit. + +**Commands**: + +* `deploy`: Deploy a Modal app from a Python file. +* `list`: List your Modal apps. +* `show`: Show details of a Modal app. +* `delete`: Delete a Modal app and its functions. + +##### `garden-ai function modal app deploy` + +Deploy a Modal app from a Python file. + +**Usage**: + +```console +$ garden-ai function modal app deploy [OPTIONS] FILE +``` + +**Arguments**: + +* `FILE`: Path to Modal Python file [required] + +**Options**: + +* `-n, --name TEXT`: App name (auto-detected if not provided) +* `-t, --title TEXT`: Title for functions (defaults to app name) +* `-a, --authors TEXT`: Comma-separated list of authors +* `--tags TEXT`: Comma-separated list of tags +* `--base-image TEXT`: Base Docker image [default: python:3.11-slim] +* `-r, --requirements TEXT`: Comma-separated pip requirements +* `--wait / --no-wait`: Wait for deployment to complete [default: wait] +* `--timeout FLOAT`: Deployment timeout in seconds [default: 300.0] +* `--help`: Show this message and exit. + +##### `garden-ai function modal app list` + +List your Modal apps. + +**Usage**: + +```console +$ garden-ai function modal app list [OPTIONS] +``` + +**Options**: + +* `--json`: Output results as JSON +* `--pretty`: Pretty-print JSON output +* `--help`: Show this message and exit. + +##### `garden-ai function modal app show` + +Show details of a Modal app. + +**Usage**: + +```console +$ garden-ai function modal app show [OPTIONS] APP_ID +``` + +**Arguments**: + +* `APP_ID`: Modal app ID [required] + +**Options**: + +* `-c, --code`: Show file contents +* `--show-app-text`: Show the app_text field (deployed code) +* `--json`: Output results as JSON +* `--pretty`: Pretty-print JSON output +* `--help`: Show this message and exit. + +##### `garden-ai function modal app delete` + +Delete a Modal app and its functions. + +**Usage**: + +```console +$ garden-ai function modal app delete [OPTIONS] APP_ID +``` + +**Arguments**: + +* `APP_ID`: Modal app ID to delete [required] + +**Options**: + +* `-f, --force`: Skip confirmation +* `--help`: Show this message and exit. + +### `garden-ai function hpc` + +Manage HPC functions + +**Usage**: + +```console +$ garden-ai function hpc [OPTIONS] COMMAND [ARGS]... +``` + +**Options**: + +* `--help`: Show this message and exit. + +**Commands**: + +* `deploy`: Deploy a HPC function from a Python file. +* `list`: List your HPC functions. +* `show`: Show details of a HPC function. +* `update`: Update a HPC function's metadata. +* `delete`: Delete a HPC function. +* `endpoint`: Manage HPC endpoints + +#### `garden-ai function hpc deploy` + +Deploy a HPC function from a Python file. + +**Usage**: + +```console +$ garden-ai function hpc deploy [OPTIONS] FILE +``` + +**Arguments**: + +* `FILE`: Path to Python file with function [required] + +**Options**: + +* `-n, --name TEXT`: Function name (auto-detected if not provided) +* `-t, --title TEXT`: Function title +* `-e, --endpoint-ids TEXT`: Comma-separated endpoint IDs [required] +* `-a, --authors TEXT`: Comma-separated authors +* `-d, --description TEXT`: Function description +* `--tags TEXT`: Comma-separated tags +* `-r, --requirements TEXT`: Comma-separated pip requirements +* `--help`: Show this message and exit. + +#### `garden-ai function hpc list` + +List your HPC functions. + +**Usage**: + +```console +$ garden-ai function hpc list [OPTIONS] +``` + +**Options**: + +* `--json`: Output results as JSON +* `--pretty`: Pretty-print JSON output +* `--help`: Show this message and exit. + +#### `garden-ai function hpc show` + +Show details of a HPC function. + +**Usage**: + +```console +$ garden-ai function hpc show [OPTIONS] FUNCTION_ID +``` + +**Arguments**: + +* `FUNCTION_ID`: Function ID [required] + +**Options**: + +* `-c, --code`: Show function code +* `--json`: Output results as JSON +* `--pretty`: Pretty-print JSON output +* `--help`: Show this message and exit. + +#### `garden-ai function hpc update` + +Update a HPC function's metadata. + +**Usage**: + +```console +$ garden-ai function hpc update [OPTIONS] FUNCTION_ID +``` + +**Arguments**: + +* `FUNCTION_ID`: Function ID [required] + +**Options**: + +* `-n, --name TEXT`: New function name +* `-t, --title TEXT`: New title +* `-d, --description TEXT`: New description +* `-a, --authors TEXT`: New comma-separated authors +* `--tags TEXT`: New comma-separated tags +* `-e, --endpoint-ids TEXT`: New comma-separated endpoint IDs +* `--help`: Show this message and exit. + +#### `garden-ai function hpc delete` + +Delete a HPC function. + +**Usage**: + +```console +$ garden-ai function hpc delete [OPTIONS] FUNCTION_ID +``` + +**Arguments**: + +* `FUNCTION_ID`: Function ID [required] + +**Options**: + +* `-f, --force`: Skip confirmation +* `--help`: Show this message and exit. + +#### `garden-ai function hpc endpoint` + +Manage HPC endpoints + +**Usage**: + +```console +$ garden-ai function hpc endpoint [OPTIONS] COMMAND [ARGS]... +``` + +**Options**: + +* `--help`: Show this message and exit. + +**Commands**: + +* `create`: Register a new HPC endpoint. +* `list`: List available HPC endpoints. +* `show`: Show details of a HPC endpoint. +* `update`: Update a HPC endpoint. +* `delete`: Delete a HPC endpoint. + +##### `garden-ai function hpc endpoint create` + +Register a new HPC endpoint. + +**Usage**: + +```console +$ garden-ai function hpc endpoint create [OPTIONS] +``` + +**Options**: + +* `-n, --name TEXT`: Endpoint name [required] +* `-g, --gcmu-id TEXT`: Globus Compute endpoint UUID +* `--help`: Show this message and exit. + +##### `garden-ai function hpc endpoint list` + +List available HPC endpoints. + +**Usage**: + +```console +$ garden-ai function hpc endpoint list [OPTIONS] +``` + +**Options**: + +* `-n, --limit INTEGER`: Maximum results [default: 50] +* `--json`: Output results as JSON +* `--pretty`: Pretty-print JSON output +* `--help`: Show this message and exit. + +##### `garden-ai function hpc endpoint show` + +Show details of a HPC endpoint. + +**Usage**: + +```console +$ garden-ai function hpc endpoint show [OPTIONS] ENDPOINT_ID +``` + +**Arguments**: + +* `ENDPOINT_ID`: Endpoint ID [required] + +**Options**: + +* `--json`: Output results as JSON +* `--pretty`: Pretty-print JSON output +* `--help`: Show this message and exit. + +##### `garden-ai function hpc endpoint update` + +Update a HPC endpoint. + +**Usage**: + +```console +$ garden-ai function hpc endpoint update [OPTIONS] ENDPOINT_ID +``` + +**Arguments**: + +* `ENDPOINT_ID`: Endpoint ID [required] + +**Options**: + +* `-n, --name TEXT`: New name +* `-g, --gcmu-id TEXT`: New GCMU ID +* `--help`: Show this message and exit. + +##### `garden-ai function hpc endpoint delete` + +Delete a HPC endpoint. + +**Usage**: + +```console +$ garden-ai function hpc endpoint delete [OPTIONS] ENDPOINT_ID +``` + +**Arguments**: + +* `ENDPOINT_ID`: Endpoint ID [required] + +**Options**: + +* `-f, --force`: Skip confirmation +* `--help`: Show this message and exit. diff --git a/docs/index.md b/docs/index.md index 83bba82a..90600ec9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,6 +29,7 @@ New to Garden? Here are some great places to begin: ## For Users - [Publishing with Modal](user_guide/publishing/modal-publishing.md) - Learn how to publish a Garden using [Modal](https://modal.com) +- [CLI reference](CLI.md) - Learn how to use the `garden-ai` CLI. ## Additional Resources diff --git a/garden_ai/app/functions.py b/garden_ai/app/functions.py new file mode 100644 index 00000000..2bd1dfbc --- /dev/null +++ b/garden_ai/app/functions.py @@ -0,0 +1,12 @@ +"""CLI commands for function management (Modal + HPC).""" + +import typer + +from garden_ai.app.groundhog import groundhog_app +from garden_ai.app.modal_cmds import modal_app + +functions_app = typer.Typer( + help="Manage functions (Modal and HPC)", no_args_is_help=True +) +functions_app.add_typer(modal_app, name="modal") +functions_app.add_typer(groundhog_app, name="hpc") diff --git a/garden_ai/app/garden.py b/garden_ai/app/garden.py new file mode 100644 index 00000000..7e0a9171 --- /dev/null +++ b/garden_ai/app/garden.py @@ -0,0 +1,418 @@ +"""CLI commands for Garden management.""" + +import json +from typing import Optional + +import rich +import typer +from rich.table import Table + +from garden_ai import GardenClient +from garden_ai.app.utils import parse_int_list, parse_list +from garden_ai.schemas.garden import GardenCreateRequest, GardenPatchRequest + +garden_app = typer.Typer(help="Manage Gardens", no_args_is_help=True) + + +@garden_app.command("create") +def create_garden( + title: str = typer.Option(..., "--title", "-t", help="Title of the garden"), + authors: Optional[str] = typer.Option( + None, + "--authors", + "-a", + help="Comma-separated list of authors. If not provided, uses the current user.", + ), + contributors: Optional[str] = typer.Option( + None, "--contributors", "-c", help="Comma-separated list of contributors" + ), + description: Optional[str] = typer.Option( + None, "--description", "-d", help="Description of the garden" + ), + tags: Optional[str] = typer.Option( + None, "--tags", help="Comma-separated list of tags" + ), + modal_function_ids: Optional[str] = typer.Option( + None, "--modal-function-ids", "-m", help="Comma-separated Modal function IDs" + ), + hpc_function_ids: Optional[str] = typer.Option( + None, "--hpc-function-ids", "-g", help="Comma-separated HPC function IDs" + ), + year: str = typer.Option("2026", "--year", help="Publication year"), + version: str = typer.Option("0.0.1", "--version", help="Garden version"), +): + """Create a new garden.""" + client = GardenClient() + + # If no authors provided, use the current user + author_list = parse_list(authors) if authors else [client.get_email()] + + request = GardenCreateRequest( + title=title, + authors=author_list, + contributors=parse_list(contributors), + description=description, + tags=parse_list(tags), + modal_function_ids=parse_int_list(modal_function_ids), + hpc_function_ids=parse_int_list(hpc_function_ids), + year=year, + version=version, + ) + + garden = client.backend_client.create_garden(request) + + rich.print("[green]✓[/green] Garden created successfully!") + rich.print(f" DOI: [bold]{garden.doi}[/bold]") + rich.print(f" Title: {garden.title}") + if garden.modal_function_ids: + rich.print(f" Modal Functions: {garden.modal_function_ids}") + if garden.hpc_function_ids: + rich.print(f" HPC Functions: {garden.hpc_function_ids}") + + +@garden_app.command("list") +def list_gardens( + all_gardens: bool = typer.Option( + False, "--all", help="List all published gardens instead of just yours" + ), + tags: Optional[str] = typer.Option( + None, "--tags", help="Filter by comma-separated tags" + ), + authors: Optional[str] = typer.Option( + None, "--authors", help="Filter by comma-separated authors" + ), + year: Optional[str] = typer.Option(None, "--year", help="Filter by year"), + limit: int = typer.Option(20, "--limit", "-n", help="Maximum results to show"), + json_output: bool = typer.Option(False, "--json", help="Output results as JSON"), + pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON output"), +): + """List gardens.""" + client = GardenClient() + + owner_uuid: str | None = str(client.get_user_identity_id()) + if all_gardens: + owner_uuid = None + + gardens = client.backend_client.get_gardens( + tags=parse_list(tags), + authors=parse_list(authors), + year=year, + owner_uuid=owner_uuid, + limit=limit, + ) + + if json_output: + data = [g.metadata.model_dump(mode="json") for g in gardens] + if pretty: + rich.print(data) + else: + print(json.dumps(data)) + return + + if not gardens: + rich.print("[yellow]No gardens found.[/yellow]") + return + + table = Table(title="Gardens") + table.add_column("DOI", style="cyan") + table.add_column("Title") + table.add_column("Authors") + table.add_column("State") + + for g in gardens: + state = g.metadata.state or ( + "draft" if g.metadata.doi_is_draft else "published" + ) + title = g.metadata.title + table.add_row( + g.metadata.doi, + title[:40] + "..." if len(title) > 40 else title, + ", ".join(g.metadata.authors[:2]) + + ("..." if len(g.metadata.authors) > 2 else ""), + state, + ) + + rich.print(table) + + +@garden_app.command("search") +def search_garden( + query: str = typer.Argument(..., help="Search query"), + limit: int = typer.Option(10, "--limit", "-n", help="Maximum results to show"), + json_output: bool = typer.Option(False, "--json", help="Output results as JSON"), + pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON output"), +): + """Search for gardens using full-text search.""" + client = GardenClient() + + payload = { + "q": query, + "limit": limit, + "offset": 0, + "filters": [{"field_name": "is_archived", "values": ["false"]}], + } + + results = client.backend_client.search_gardens(payload) + gardens = results.get("garden_meta", []) + + if json_output: + if pretty: + rich.print(gardens) + else: + import json + + print(json.dumps(gardens)) + return + + if not gardens: + rich.print("[yellow]No gardens found.[/yellow]") + return + + table = Table(title=f"Search Results for '{query}'") + table.add_column("DOI", style="cyan") + table.add_column("Title") + table.add_column("Authors") + table.add_column("Description") + + for g in gardens: + title = g.get("title", "") + title_display = title[:40] + "..." if len(title) > 40 else title + authors = g.get("authors", []) + authors_display = ", ".join(authors[:2]) + ("..." if len(authors) > 2 else "") + description = g.get("description", "") or "" + desc_display = ( + description[:50] + "..." if len(description) > 50 else description + ) + + table.add_row( + g.get("doi", "-"), + title_display, + authors_display, + desc_display, + ) + + rich.print(table) + + +@garden_app.command("show") +def show_garden( + doi: str = typer.Argument(..., help="DOI of the garden to show"), + json_output: bool = typer.Option(False, "--json", help="Output results as JSON"), + pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON output"), +): + """Show details of a specific garden.""" + client = GardenClient() + + garden = client.backend_client.get_garden(doi) + meta = garden.metadata + + if json_output: + data = meta.model_dump(mode="json") + # Add function names to the output + data["modal_functions"] = [ + {"id": fn.metadata.id, "name": fn.metadata.function_name} + for fn in garden.modal_functions + ] + data["modal_classes"] = [ + { + "class_name": cls.class_name, + "methods": [ + {"id": m.metadata.id, "name": m.metadata.function_name} + for m in cls._methods.values() + ], + } + for cls in garden.modal_classes + ] + data["hpc_functions"] = [ + {"id": fn.metadata.id, "name": fn.metadata.function_name} + for fn in garden.hpc_functions + ] + if pretty: + rich.print(data) + else: + print(json.dumps(data)) + return + + rich.print(f"\n[bold]Garden: {meta.title}[/bold]") + rich.print(f" DOI: [cyan]{meta.doi}[/cyan]") + rich.print( + f" State: {meta.state or ('draft' if meta.doi_is_draft else 'published')}" + ) + rich.print(f" Authors: {', '.join(meta.authors)}") + if meta.contributors: + rich.print(f" Contributors: {', '.join(meta.contributors)}") + if meta.description: + rich.print(f" Description: {meta.description}") + if meta.tags: + rich.print(f" Tags: {', '.join(meta.tags)}") + rich.print(f" Year: {meta.year}") + rich.print(f" Version: {meta.version}") + + # Display Modal functions with names + if garden.modal_functions or garden.modal_classes: + rich.print("\n [bold]Modal Functions:[/bold]") + for fn in garden.modal_functions: + rich.print(f" - {fn.metadata.function_name} (ID: {fn.metadata.id})") + for cls in garden.modal_classes: + rich.print( + f" - {cls.class_name} [class] (IDs: {[m.metadata.id for m in cls._methods.values()]})" + ) + + # Display HPC functions with names + if garden.hpc_functions: + rich.print("\n [bold]HPC Functions:[/bold]") + for hpc_fn in garden.hpc_functions: + rich.print( + f" - {hpc_fn.metadata.function_name} (ID: {hpc_fn.metadata.id})" + ) + + +@garden_app.command("update") +def update_garden( + doi: str = typer.Argument(..., help="DOI of the garden to update"), + title: Optional[str] = typer.Option(None, "--title", "-t", help="New title"), + authors: Optional[str] = typer.Option( + None, + "--authors", + "-a", + help="Comma-separated list of authors. Replaces the entire authors list.", + ), + contributors: Optional[str] = typer.Option( + None, + "--contributors", + "-c", + help="Comma-separated list of contributors. Replaces the entire contributors list.", + ), + description: Optional[str] = typer.Option( + None, "--description", "-d", help="New description" + ), + tags: Optional[str] = typer.Option( + None, + "--tags", + help="Comma-separated list of tags. Replaces the entire tags list.", + ), + version: Optional[str] = typer.Option(None, "--version", help="New version"), + modal_function_ids: Optional[str] = typer.Option( + None, + "--modal-function-ids", + "-m", + help="Comma-separated Modal function IDs. Replaces the entire list.", + ), + hpc_function_ids: Optional[str] = typer.Option( + None, + "--hpc-function-ids", + "-g", + help="Comma-separated HPC function IDs. Replaces the entire list.", + ), +): + """Update a garden's metadata. + + Note: For list fields (authors, contributors, tags, function IDs), the provided + values will REPLACE the entire existing list. To add or remove individual items, + first retrieve the current values with 'garden show', modify as needed, then + provide the complete new list. + """ + client = GardenClient() + + request = GardenPatchRequest( + title=title, + authors=parse_list(authors) if authors else None, + contributors=parse_list(contributors) if contributors else None, + description=description, + tags=parse_list(tags) if tags else None, + version=version, + modal_function_ids=parse_int_list(modal_function_ids) + if modal_function_ids + else None, + hpc_function_ids=parse_int_list(hpc_function_ids) if hpc_function_ids else None, + ) + + # Only send if at least one field is set + if not any( + [ + request.title, + request.authors, + request.contributors, + request.description, + request.tags, + request.version, + request.modal_function_ids, + request.hpc_function_ids, + ] + ): + rich.print("[yellow]No updates specified.[/yellow]") + raise typer.Exit(1) + + garden = client.backend_client.patch_garden(doi, request) + + rich.print("[green]✓[/green] Garden updated successfully!") + rich.print(f" DOI: [bold]{garden.doi}[/bold]") + + +@garden_app.command("add-functions") +def add_functions( + doi: str = typer.Argument(..., help="DOI of the garden"), + modal_function_ids: Optional[str] = typer.Option( + None, + "--modal-function-ids", + "-m", + help="Comma-separated Modal function IDs to add", + ), + hpc_function_ids: Optional[str] = typer.Option( + None, + "--hpc-function-ids", + "-g", + help="Comma-separated HPC function IDs to add", + ), + replace: bool = typer.Option( + False, "--replace", help="Replace existing functions instead of adding" + ), +): + """Add functions to an existing garden.""" + client = GardenClient() + + modal_ids = parse_int_list(modal_function_ids) + hpc_ids = parse_int_list(hpc_function_ids) + + if not modal_ids and not hpc_ids: + rich.print("[yellow]No function IDs specified.[/yellow]") + raise typer.Exit(1) + + if not replace: + # Get current garden to merge function IDs + current = client.backend_client.get_garden_metadata(doi) + if modal_ids: + modal_ids = list(set(current.modal_function_ids + modal_ids)) + if hpc_ids: + hpc_ids = list(set(current.hpc_function_ids + hpc_ids)) + + request = GardenPatchRequest( + modal_function_ids=modal_ids if modal_ids else None, + hpc_function_ids=hpc_ids if hpc_ids else None, + ) + + garden = client.backend_client.patch_garden(doi, request) + + rich.print( + f"[green]✓[/green] Functions {'replaced' if replace else 'added'} successfully!" + ) + rich.print(f" Modal Functions: {garden.modal_function_ids}") + rich.print(f" HPC Functions: {garden.hpc_function_ids}") + + +@garden_app.command("delete") +def delete_garden( + doi: str = typer.Argument(..., help="DOI of the garden to delete"), + force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt"), +): + """Delete a garden.""" + if not force: + confirm = typer.confirm(f"Are you sure you want to delete garden {doi}?") + if not confirm: + rich.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit(0) + + client = GardenClient() + client.backend_client.delete_garden(doi) + + rich.print(f"[green]✓[/green] Garden {doi} deleted successfully!") diff --git a/garden_ai/app/groundhog.py b/garden_ai/app/groundhog.py new file mode 100644 index 00000000..f91082b0 --- /dev/null +++ b/garden_ai/app/groundhog.py @@ -0,0 +1,449 @@ +"""CLI commands for HPC (HPC) function management.""" + +import ast +import json +from datetime import datetime +from pathlib import Path +from typing import Optional + +import rich +import typer +from rich.table import Table + +from garden_ai import GardenClient +from garden_ai.app.utils import parse_int_list, parse_list +from garden_ai.schemas.groundhog import ( + HpcEndpointCreateRequest, + HpcEndpointPatchRequest, + HpcFunctionCreateRequest, + HpcFunctionPatchRequest, +) + +groundhog_app = typer.Typer(help="Manage HPC functions", no_args_is_help=True) +endpoint_app = typer.Typer(help="Manage HPC endpoints", no_args_is_help=True) +groundhog_app.add_typer(endpoint_app, name="endpoint") + + +def _has_hog_decorator(node: ast.FunctionDef) -> bool: + """Check if a function/method has @hog.function or @hog.method decorator.""" + for decorator in node.decorator_list: + # Handle @hog.function() or @hog.method() with parens + if isinstance(decorator, ast.Call) and isinstance( + decorator.func, ast.Attribute + ): + if ( + isinstance(decorator.func.value, ast.Name) + and decorator.func.value.id == "hog" + and decorator.func.attr in ("function", "method") + ): + return True + # Handle @hog.function or @hog.method without parens + if isinstance(decorator, ast.Attribute): + if ( + isinstance(decorator.value, ast.Name) + and decorator.value.id == "hog" + and decorator.attr in ("function", "method") + ): + return True + return False + + +def _extract_function_from_file(file_path: Path) -> dict: + """Extract function info from a groundhog HPC Python file. + + Looks for functions/methods decorated with @hog.function or @hog.method. + For methods, returns the fully qualified name as ClassName.method_name. + Falls back to filename if no decorated function is found. + """ + content = file_path.read_text() + tree = ast.parse(content) + + # First, look for @hog.function decorated functions at module level + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.FunctionDef) and _has_hog_decorator(node): + return { + "function_name": node.name, + "docstring": ast.get_docstring(node) or "", + "function_text": content, + } + + # Then, look for @hog.method decorated methods inside classes + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.ClassDef): + class_name = node.name + for class_node in ast.iter_child_nodes(node): + if isinstance(class_node, ast.FunctionDef) and _has_hog_decorator( + class_node + ): + return { + "function_name": f"{class_name}.{class_node.name}", + "docstring": ast.get_docstring(class_node) or "", + "function_text": content, + } + + # Fallback to filename + return { + "function_name": file_path.stem, + "docstring": "", + "function_text": content, + } + + +# ============================================================================= +# Endpoint Commands +# ============================================================================= + + +@endpoint_app.command("create") +def create_endpoint( + name: str = typer.Option(..., "--name", "-n", help="Endpoint name"), + gcmu_id: Optional[str] = typer.Option( + None, "--gcmu-id", "-g", help="Globus Compute endpoint UUID" + ), +): + """Register a new HPC endpoint.""" + client = GardenClient() + + request = HpcEndpointCreateRequest(name=name, gcmu_id=gcmu_id) + endpoint = client.backend_client.create_hpc_endpoint(request) + + rich.print("[green]✓[/green] Endpoint created successfully!") + rich.print(f" ID: [bold]{endpoint.id}[/bold]") + rich.print(f" Name: {endpoint.name}") + if endpoint.gcmu_id: + rich.print(f" GCMU ID: {endpoint.gcmu_id}") + + +@endpoint_app.command("list") +def list_endpoints( + limit: int = typer.Option(50, "--limit", "-n", help="Maximum results"), + json_output: bool = typer.Option(False, "--json", help="Output results as JSON"), + pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON output"), +): + """List available HPC endpoints.""" + client = GardenClient() + endpoints = client.backend_client.get_hpc_endpoints(limit=limit) + + if json_output: + data = [ep.model_dump(mode="json") for ep in endpoints] + if pretty: + rich.print(data) + else: + print(json.dumps(data)) + return + + if not endpoints: + rich.print("[yellow]No endpoints found.[/yellow]") + return + + table = Table(title="HPC Endpoints") + table.add_column("ID", style="cyan") + table.add_column("Name") + table.add_column("GCMU ID") + table.add_column("Owner") + + for ep in endpoints: + table.add_row( + str(ep.id), + ep.name, + ep.gcmu_id or "-", + ep.owner or "-", + ) + + rich.print(table) + + +@endpoint_app.command("show") +def show_endpoint( + endpoint_id: int = typer.Argument(..., help="Endpoint ID"), + json_output: bool = typer.Option(False, "--json", help="Output results as JSON"), + pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON output"), +): + """Show details of a HPC endpoint.""" + client = GardenClient() + endpoint = client.backend_client.get_hpc_endpoint(endpoint_id) + + if json_output: + data = endpoint.model_dump(mode="json") + if pretty: + rich.print(data) + else: + print(json.dumps(data)) + return + + rich.print(f"\n[bold]Endpoint: {endpoint.name}[/bold]") + rich.print(f" ID: [cyan]{endpoint.id}[/cyan]") + if endpoint.gcmu_id: + rich.print(f" GCMU ID: {endpoint.gcmu_id}") + if endpoint.owner: + rich.print(f" Owner: {endpoint.owner}") + + +@endpoint_app.command("update") +def update_endpoint( + endpoint_id: int = typer.Argument(..., help="Endpoint ID"), + name: Optional[str] = typer.Option(None, "--name", "-n", help="New name"), + gcmu_id: Optional[str] = typer.Option(None, "--gcmu-id", "-g", help="New GCMU ID"), +): + """Update a HPC endpoint.""" + client = GardenClient() + + request = HpcEndpointPatchRequest(name=name, gcmu_id=gcmu_id) + + if not any([request.name, request.gcmu_id]): + rich.print("[yellow]No updates specified.[/yellow]") + raise typer.Exit(1) + + endpoint = client.backend_client.patch_hpc_endpoint(endpoint_id, request) + + rich.print("[green]✓[/green] Endpoint updated successfully!") + rich.print(f" ID: {endpoint.id}") + rich.print(f" Name: {endpoint.name}") + + +@endpoint_app.command("delete") +def delete_endpoint( + endpoint_id: int = typer.Argument(..., help="Endpoint ID"), + force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"), +): + """Delete a HPC endpoint.""" + client = GardenClient() + + endpoint = client.backend_client.get_hpc_endpoint(endpoint_id) + + if not force: + if not typer.confirm(f"Delete endpoint '{endpoint.name}'?"): + rich.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit(0) + + client.backend_client.delete_hpc_endpoint(endpoint_id) + rich.print(f"[green]✓[/green] Endpoint {endpoint_id} deleted successfully!") + + +# ============================================================================= +# Function Commands +# ============================================================================= + + +@groundhog_app.command("deploy") +def deploy_function( + file: Path = typer.Argument(..., help="Path to Python file with function"), + name: Optional[str] = typer.Option( + None, "--name", "-n", help="Function name (auto-detected if not provided)" + ), + title: Optional[str] = typer.Option(None, "--title", "-t", help="Function title"), + endpoint_ids: str = typer.Option( + ..., "--endpoint-ids", "-e", help="Comma-separated endpoint IDs" + ), + authors: Optional[str] = typer.Option( + None, "--authors", "-a", help="Comma-separated authors" + ), + description: Optional[str] = typer.Option( + None, "--description", "-d", help="Function description" + ), + tags: Optional[str] = typer.Option(None, "--tags", help="Comma-separated tags"), + requirements: Optional[str] = typer.Option( + None, "--requirements", "-r", help="Comma-separated pip requirements" + ), +): + """Deploy a HPC function from a Python file.""" + if not file.exists(): + rich.print(f"[red]Error:[/red] File not found: {file}") + raise typer.Exit(1) + + client = GardenClient() + + # Parse endpoint IDs + ep_ids = parse_int_list(endpoint_ids) + if not ep_ids: + rich.print("[red]Error:[/red] At least one endpoint ID is required.") + raise typer.Exit(1) + + # Extract function info from file + fn_info = _extract_function_from_file(file) + function_name = name or fn_info["function_name"] + function_title = title or function_name + + year = str(datetime.now().year) + author_list = parse_list(authors) or [client.get_email()] + + request = HpcFunctionCreateRequest( + function_name=function_name, + endpoint_ids=ep_ids, + function_text=fn_info["function_text"], + title=function_title, + description=description or fn_info["docstring"] or None, + year=year, + authors=author_list, + tags=parse_list(tags), + requirements=parse_list(requirements), + ) + + rich.print(f"\n[bold]Deploying HPC function:[/bold] {function_name}") + + fn = client.backend_client.create_hpc_function(request) + + rich.print("\n[green]✓[/green] Function deployed successfully!") + rich.print(f" ID: [bold]{fn.id}[/bold]") + rich.print(f" Name: {fn.function_name}") + rich.print(f" Title: {fn.title}") + if fn.available_endpoints: + ep_names = [ep.name for ep in fn.available_endpoints] + rich.print(f" Endpoints: {', '.join(ep_names)}") + + +@groundhog_app.command("list") +def list_functions( + json_output: bool = typer.Option(False, "--json", help="Output results as JSON"), + pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON output"), +): + """List your HPC functions.""" + client = GardenClient() + functions = client.backend_client.get_hpc_functions() + + if json_output: + data = [fn.model_dump(mode="json") for fn in functions] + if pretty: + rich.print(data) + else: + print(json.dumps(data)) + return + + if not functions: + rich.print("[yellow]No HPC functions found.[/yellow]") + return + + table = Table(title="HPC Functions") + table.add_column("ID", style="cyan") + table.add_column("Name") + table.add_column("Title") + table.add_column("Endpoints") + table.add_column("Invocations") + + for fn in functions: + ep_count = len(fn.available_endpoints) + table.add_row( + str(fn.id), + fn.function_name, + (fn.title[:30] + "...") + if fn.title and len(fn.title) > 30 + else (fn.title or "-"), + str(ep_count), + str(fn.num_invocations), + ) + + rich.print(table) + + +@groundhog_app.command("show") +def show_function( + function_id: int = typer.Argument(..., help="Function ID"), + show_code: bool = typer.Option(False, "--code", "-c", help="Show function code"), + json_output: bool = typer.Option(False, "--json", help="Output results as JSON"), + pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON output"), +): + """Show details of a HPC function.""" + client = GardenClient() + fn = client.backend_client.get_hpc_function(function_id) + + if json_output: + data = fn.model_dump(mode="json") + if pretty: + rich.print(data) + else: + print(json.dumps(data)) + return + + rich.print(f"\n[bold]HPC Function: {fn.function_name}[/bold]") + rich.print(f" ID: [cyan]{fn.id}[/cyan]") + if fn.title: + rich.print(f" Title: {fn.title}") + if fn.description: + rich.print(f" Description: {fn.description}") + if fn.authors: + rich.print(f" Authors: {', '.join(fn.authors)}") + if fn.tags: + rich.print(f" Tags: {', '.join(fn.tags)}") + rich.print(f" Year: {fn.year}") + rich.print(f" Invocations: {fn.num_invocations}") + rich.print(f" Archived: {fn.is_archived}") + + if fn.available_endpoints: + rich.print("\n [bold]Available Endpoints:[/bold]") + for ep in fn.available_endpoints: + gcmu = f" (GCMU: {ep.gcmu_id})" if ep.gcmu_id else "" + rich.print(f" - {ep.name}{gcmu}") + + if show_code and fn.function_text: + rich.print("\n [bold]Function Code:[/bold]") + from rich.syntax import Syntax + + syntax = Syntax(fn.function_text, "python", theme="monokai", line_numbers=True) + rich.print(syntax) + + +@groundhog_app.command("update") +def update_function( + function_id: int = typer.Argument(..., help="Function ID"), + name: Optional[str] = typer.Option(None, "--name", "-n", help="New function name"), + title: Optional[str] = typer.Option(None, "--title", "-t", help="New title"), + description: Optional[str] = typer.Option( + None, "--description", "-d", help="New description" + ), + authors: Optional[str] = typer.Option( + None, "--authors", "-a", help="New comma-separated authors" + ), + tags: Optional[str] = typer.Option(None, "--tags", help="New comma-separated tags"), + endpoint_ids: Optional[str] = typer.Option( + None, "--endpoint-ids", "-e", help="New comma-separated endpoint IDs" + ), +): + """Update a HPC function's metadata.""" + client = GardenClient() + + request = HpcFunctionPatchRequest( + function_name=name, + title=title, + description=description, + authors=parse_list(authors) if authors else None, + tags=parse_list(tags) if tags else None, + endpoint_ids=parse_int_list(endpoint_ids) if endpoint_ids else None, + ) + + if not any( + [ + request.function_name, + request.title, + request.description, + request.authors, + request.tags, + request.endpoint_ids, + ] + ): + rich.print("[yellow]No updates specified.[/yellow]") + raise typer.Exit(1) + + fn = client.backend_client.patch_hpc_function(function_id, request) + + rich.print("[green]✓[/green] Function updated successfully!") + rich.print(f" ID: {fn.id}") + rich.print(f" Name: {fn.function_name}") + + +@groundhog_app.command("delete") +def delete_function( + function_id: int = typer.Argument(..., help="Function ID"), + force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"), +): + """Delete a HPC function.""" + client = GardenClient() + + fn = client.backend_client.get_hpc_function(function_id) + + if not force: + if not typer.confirm(f"Delete function '{fn.function_name}'?"): + rich.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit(0) + + client.backend_client.delete_hpc_function(function_id) + rich.print(f"[green]✓[/green] Function {function_id} deleted successfully!") diff --git a/garden_ai/app/main.py b/garden_ai/app/main.py index 14f80c92..e8686936 100644 --- a/garden_ai/app/main.py +++ b/garden_ai/app/main.py @@ -8,6 +8,8 @@ from garden_ai import GardenClient, GardenConstants from garden_ai._version import __version__ +from garden_ai.app.functions import functions_app +from garden_ai.app.garden import garden_app from garden_ai.app.mcp import mcp_app logger = logging.getLogger() @@ -15,6 +17,8 @@ app = typer.Typer(no_args_is_help=True) app.add_typer(mcp_app, name="mcp") +app.add_typer(garden_app, name="garden") +app.add_typer(functions_app, name="function") def show_version(show: bool): diff --git a/garden_ai/app/modal_cmds.py b/garden_ai/app/modal_cmds.py new file mode 100644 index 00000000..ebbc49b8 --- /dev/null +++ b/garden_ai/app/modal_cmds.py @@ -0,0 +1,411 @@ +"""CLI commands for Modal function and app management.""" + +import json +from pathlib import Path +from typing import Optional + +import rich +import typer +from rich.progress import Progress, SpinnerColumn, TextColumn +from rich.table import Table + +from garden_ai import GardenClient +from garden_ai.app.utils import parse_list +from garden_ai.schemas.modal_app import ( + AsyncModalJobStatus, + ModalAppCreateRequest, + ModalFunctionCreateMetadata, + ModalFunctionPatchRequest, +) + +modal_app = typer.Typer(help="Manage Modal functions and apps", no_args_is_help=True) +modal_app_app = typer.Typer(help="Manage Modal apps", no_args_is_help=True) +modal_app.add_typer(modal_app_app, name="app") + + +# ============================================================================= +# Modal Function Commands +# ============================================================================= + + +@modal_app.command("list") +def list_modal_functions( + limit: int = typer.Option(50, "--limit", "-n", help="Maximum results"), + json_output: bool = typer.Option(False, "--json", help="Output results as JSON"), + pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON output"), +): + """List your Modal functions.""" + client = GardenClient() + functions = client.backend_client.get_modal_functions(limit=limit) + + if json_output: + data = [fn.model_dump(mode="json") for fn in functions] + if pretty: + rich.print(data) + else: + print(json.dumps(data)) + return + + if not functions: + rich.print("[yellow]No Modal functions found.[/yellow]") + return + + table = Table(title="Modal Functions") + table.add_column("ID", style="cyan") + table.add_column("Name") + table.add_column("Title") + table.add_column("App ID") + table.add_column("Authors") + table.add_column("Invocations", justify="right") + + for fn in functions: + title = fn.title or "-" + title_display = (title[:25] + "...") if len(title) > 25 else title + authors = ", ".join(fn.authors[:2]) if fn.authors else "-" + if fn.authors and len(fn.authors) > 2: + authors += "..." + + table.add_row( + str(fn.id), + fn.function_name or "-", + title_display, + str(fn.modal_app_id) if fn.modal_app_id else "-", + authors, + str(fn.num_invocations), + ) + + rich.print(table) + + +@modal_app.command("show") +def show_modal_function( + function_id: int = typer.Argument(..., help="Modal function ID"), + show_code: bool = typer.Option(False, "--code", "-c", help="Show function code"), + json_output: bool = typer.Option(False, "--json", help="Output results as JSON"), + pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON output"), +): + """Show details of a Modal function.""" + client = GardenClient() + fn = client.backend_client.get_modal_function(function_id) + + if json_output: + data = fn.model_dump(mode="json") + if pretty: + rich.print(data) + else: + print(json.dumps(data)) + return + + rich.print(f"\n[bold]Modal Function: {fn.function_name}[/bold]") + rich.print(f" ID: [cyan]{fn.id}[/cyan]") + rich.print(f" App ID: {fn.modal_app_id}") + if fn.doi: + rich.print(f" DOI: {fn.doi}") + rich.print(f" Title: {fn.title}") + if fn.description: + rich.print(f" Description: {fn.description}") + if fn.authors: + rich.print(f" Authors: {', '.join(fn.authors)}") + if fn.tags: + rich.print(f" Tags: {', '.join(fn.tags)}") + rich.print(f" Year: {fn.year}") + rich.print(f" Invocations: {fn.num_invocations}") + rich.print(f" Archived: {fn.is_archived}") + + if show_code and fn.function_text: + rich.print("\n [bold]Function Code:[/bold]") + from rich.syntax import Syntax + + syntax = Syntax(fn.function_text, "python", theme="monokai", line_numbers=True) + rich.print(syntax) + + +@modal_app.command("update") +def update_modal_function( + function_id: int = typer.Argument(..., help="Modal function ID"), + title: Optional[str] = typer.Option(None, "--title", "-t", help="New title"), + description: Optional[str] = typer.Option( + None, "--description", "-d", help="New description" + ), + authors: Optional[str] = typer.Option( + None, "--authors", "-a", help="New comma-separated authors" + ), + tags: Optional[str] = typer.Option(None, "--tags", help="New comma-separated tags"), +): + """Update a Modal function's metadata.""" + client = GardenClient() + + request = ModalFunctionPatchRequest( + title=title, + description=description, + authors=parse_list(authors) if authors else None, + tags=parse_list(tags) if tags else None, + ) + + if not any([request.title, request.description, request.authors, request.tags]): + rich.print("[yellow]No updates specified.[/yellow]") + raise typer.Exit(1) + + fn = client.backend_client.patch_modal_function(function_id, request) + + rich.print("[green]✓[/green] Function updated successfully!") + rich.print(f" ID: {fn.id}") + rich.print(f" Name: {fn.function_name}") + + +# ============================================================================= +# Modal App Commands +# ============================================================================= + + +@modal_app_app.command("deploy") +def deploy_modal_app( + file: Path = typer.Argument(..., help="Path to Modal Python file"), + app_name: Optional[str] = typer.Option( + None, "--name", "-n", help="App name (auto-detected if not provided)" + ), + title: Optional[str] = typer.Option( + None, "--title", "-t", help="Title for functions (defaults to app name)" + ), + authors: Optional[str] = typer.Option( + None, "--authors", "-a", help="Comma-separated list of authors" + ), + tags: Optional[str] = typer.Option( + None, "--tags", help="Comma-separated list of tags" + ), + base_image: str = typer.Option( + "python:3.11-slim", "--base-image", help="Base Docker image" + ), + requirements: Optional[str] = typer.Option( + None, "--requirements", "-r", help="Comma-separated pip requirements" + ), + wait: bool = typer.Option( + True, "--wait/--no-wait", help="Wait for deployment to complete" + ), + timeout: float = typer.Option( + 300.0, "--timeout", help="Deployment timeout in seconds" + ), +): + """Deploy a Modal app from a Python file.""" + if not file.exists(): + rich.print(f"[red]Error:[/red] File not found: {file}") + raise typer.Exit(1) + + client = GardenClient() + file_contents = file.read_text() + + # Parse file using backend + rich.print("[dim]Parsing Modal file...[/dim]") + try: + parsed = client.backend_client.parse_modal_file(file_contents) + except Exception as e: + rich.print(f"[red]Error parsing file:[/red] {e}") + raise typer.Exit(1) + + # Use parsed app name if not provided by user + if not app_name: + app_name = parsed.app_name or file.stem + rich.print(f"[dim]Using app name: {app_name}[/dim]") + + if not parsed.modal_functions: + rich.print("[yellow]Warning:[/yellow] No Modal functions found in file.") + rich.print("Make sure your functions are decorated with @app.function()") + + # Build function metadata, augmenting parsed data with user-provided values + author_list = parse_list(authors) or [client.get_email()] + tag_list = parse_list(tags) + cli_requirements = parse_list(requirements) + + modal_functions = [] + for fn in parsed.modal_functions: + modal_functions.append( + ModalFunctionCreateMetadata( + function_name=fn.function_name, + title=title or fn.title or fn.function_name, + description=fn.description, + year=fn.year, + authors=author_list if authors else fn.authors or author_list, + tags=tag_list if tags else fn.tags, + function_text=fn.function_text, + requirements=cli_requirements if requirements else fn.requirements, + ) + ) + + request = ModalAppCreateRequest( + app_name=app_name, + file_contents=file_contents, + base_image_name=base_image, + requirements=cli_requirements or parsed.requirements, + modal_functions=modal_functions, + ) + + rich.print(f"\n[bold]Deploying Modal app:[/bold] {app_name}") + + if wait: + # Use async endpoint and poll + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + ) as progress: + task = progress.add_task("Deploying...", total=None) + + app_response = client.backend_client.create_modal_app_async(request) + progress.update( + task, description=f"Deployment started (ID: {app_response.id})" + ) + + try: + app_response = client.backend_client.poll_modal_app_deployment( + app_response.id, timeout=timeout + ) + except TimeoutError as e: + rich.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + if app_response.deploy_status == AsyncModalJobStatus.FAILED: + rich.print("[red]✗ Deployment failed![/red]") + if app_response.deploy_error: + rich.print(f" Error: {app_response.deploy_error}") + if app_response.suggested_fix: + rich.print(f" Suggested fix: {app_response.suggested_fix}") + raise typer.Exit(1) + else: + app_response = client.backend_client.create_modal_app_async(request) + + rich.print("\n[green]✓[/green] Modal app deployed successfully!") + rich.print(f" App ID: [bold]{app_response.id}[/bold]") + rich.print(f" App Name: {app_response.app_name}") + rich.print(f" Status: {app_response.deploy_status or 'pending'}") + + if app_response.modal_functions: + rich.print("\n [bold]Functions:[/bold]") + for modal_fn in app_response.modal_functions: + rich.print(f" - {modal_fn.function_name} (ID: {modal_fn.id})") + + +@modal_app_app.command("list") +def list_modal_apps( + json_output: bool = typer.Option(False, "--json", help="Output results as JSON"), + pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON output"), +): + """List your Modal apps.""" + client = GardenClient() + apps = client.backend_client.get_modal_apps() + + if json_output: + data = [app.model_dump(mode="json") for app in apps] + if pretty: + rich.print(data) + else: + print(json.dumps(data)) + return + + if not apps: + rich.print("[yellow]No Modal apps found.[/yellow]") + return + + table = Table(title="Modal Apps") + table.add_column("ID", style="cyan") + table.add_column("Name") + table.add_column("Status") + table.add_column("Functions") + + for app in apps: + status = app.deploy_status.value if app.deploy_status else "unknown" + status_style = { + "success": "green", + "failed": "red", + "pending": "yellow", + "running": "yellow", + }.get(status, "dim") + + table.add_row( + str(app.id), + app.original_app_name or app.app_name, + f"[{status_style}]{status}[/{status_style}]", + str(len(app.modal_functions)), + ) + + rich.print(table) + + +@modal_app_app.command("show") +def show_modal_app( + app_id: int = typer.Argument(..., help="Modal app ID"), + show_code: bool = typer.Option(False, "--code", "-c", help="Show file contents"), + show_app_text: bool = typer.Option( + False, "--show-app-text", help="Show the app_text field (deployed code)" + ), + json_output: bool = typer.Option(False, "--json", help="Output results as JSON"), + pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON output"), +): + """Show details of a Modal app.""" + client = GardenClient() + app = client.backend_client.get_modal_app(app_id) + + if json_output: + data = app.model_dump(mode="json") + if pretty: + rich.print(data) + else: + print(json.dumps(data)) + return + + status = app.deploy_status.value if app.deploy_status else "unknown" + status_style = {"success": "green", "failed": "red"}.get(status, "yellow") + + rich.print(f"\n[bold]Modal App: {app.original_app_name or app.app_name}[/bold]") + rich.print(f" ID: [cyan]{app.id}[/cyan]") + rich.print(f" Status: [{status_style}]{status}[/{status_style}]") + rich.print(f" Base Image: {app.base_image_name}") + + if app.requirements: + rich.print(f" Requirements: {', '.join(app.requirements)}") + + if app.deploy_error: + rich.print(f" [red]Error:[/red] {app.deploy_error}") + if app.suggested_fix: + rich.print(f" [yellow]Fix:[/yellow] {app.suggested_fix}") + + if app.modal_functions: + rich.print(f"\n [bold]Functions ({len(app.modal_functions)}):[/bold]") + for fn in app.modal_functions: + doi_str = f" [DOI: {fn.doi}]" if fn.doi else "" + rich.print(f" - {fn.function_name} (ID: {fn.id}){doi_str}") + if fn.title and fn.title != fn.function_name: + rich.print(f" Title: {fn.title}") + + if show_code and app.file_contents: + rich.print("\n [bold]File Contents:[/bold]") + from rich.syntax import Syntax + + syntax = Syntax(app.file_contents, "python", theme="monokai", line_numbers=True) + rich.print(syntax) + + if show_app_text and hasattr(app, "app_text") and app.app_text: + rich.print("\n [bold]App Text (Deployed Code):[/bold]") + from rich.syntax import Syntax + + syntax = Syntax(app.app_text, "python", theme="monokai", line_numbers=True) + rich.print(syntax) + + +@modal_app_app.command("delete") +def delete_modal_app( + app_id: int = typer.Argument(..., help="Modal app ID to delete"), + force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"), +): + """Delete a Modal app and its functions.""" + client = GardenClient() + + # Get app details first + app = client.backend_client.get_modal_app(app_id) + + if not force: + msg = f"Delete Modal app '{app.original_app_name or app.app_name}' with {len(app.modal_functions)} function(s)?" + if not typer.confirm(msg): + rich.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit(0) + + client.backend_client.delete_modal_app(app_id) + rich.print(f"[green]✓[/green] Modal app {app_id} deleted successfully!") diff --git a/garden_ai/app/utils.py b/garden_ai/app/utils.py new file mode 100644 index 00000000..7d45c604 --- /dev/null +++ b/garden_ai/app/utils.py @@ -0,0 +1,15 @@ +"""Common utility functions for CLI commands.""" + + +def parse_list(value: str | None) -> list[str]: + """Parse a comma-separated string into a list.""" + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] + + +def parse_int_list(value: str | None) -> list[int]: + """Parse a comma-separated string of integers.""" + if not value: + return [] + return [int(item.strip()) for item in value.split(",") if item.strip()] diff --git a/garden_ai/backend_client.py b/garden_ai/backend_client.py index 51893662..f37e8787 100644 --- a/garden_ai/backend_client.py +++ b/garden_ai/backend_client.py @@ -6,7 +6,19 @@ from garden_ai.constants import GardenConstants from garden_ai.gardens import Garden -from garden_ai.schemas.garden import GardenMetadata +from garden_ai.schemas.garden import ( + GardenCreateRequest, + GardenMetadata, + GardenPatchRequest, +) +from garden_ai.schemas.groundhog import ( + HpcEndpointCreateRequest, + HpcEndpointPatchRequest, + HpcEndpointResponse, + HpcFunctionCreateRequest, + HpcFunctionPatchRequest, + HpcFunctionResponse, +) from garden_ai.schemas.hpc import HpcInvocationCreateRequest from garden_ai.schemas.modal import ( ModalBlobUploadURLRequest, @@ -14,6 +26,15 @@ ModalInvocationRequest, ModalInvocationResponse, ) +from garden_ai.schemas.modal_app import ( + ModalAppCreateRequest, + ModalAppPatchRequest, + ModalAppResponse, + ModalFileMetadataRequest, + ModalFileMetadataResponse, + ModalFunctionPatchRequest, + ModalFunctionResponse, +) logger = logging.getLogger() @@ -182,3 +203,184 @@ def search_gardens(self, payload: dict) -> dict: def create_hpc_invocation(self, payload: HpcInvocationCreateRequest) -> dict: response = self._post("/hpc/invocations", payload.model_dump(mode="json")) return response + + def create_garden(self, payload: GardenCreateRequest) -> GardenMetadata: + """Create a new garden.""" + response = self._post("/gardens", payload.model_dump(mode="json")) + return GardenMetadata(**response) + + def patch_garden(self, doi: str, payload: GardenPatchRequest) -> GardenMetadata: + """Update an existing garden.""" + response = self._call( + requests.patch, + f"/gardens/{doi}", + payload.model_dump(mode="json", exclude_none=True), + ) + return GardenMetadata(**response) + + def parse_modal_file(self, file_contents: str) -> ModalFileMetadataResponse: + """Parse a Modal Python file and extract metadata. + + Sends file contents to backend for parsing. Returns extracted app name, + function names, requirements, and other metadata. + """ + request = ModalFileMetadataRequest(file_contents=file_contents) + response = self._post("/modal-file-metadata", request.model_dump(mode="json")) + return ModalFileMetadataResponse(**response) + + def create_modal_app(self, payload: ModalAppCreateRequest) -> ModalAppResponse: + """Deploy a Modal app synchronously.""" + response = self._post("/modal-apps", payload.model_dump(mode="json")) + return ModalAppResponse(**response) + + def create_modal_app_async( + self, payload: ModalAppCreateRequest + ) -> ModalAppResponse: + """Deploy a Modal app asynchronously. Returns immediately with pending status.""" + response = self._post("/modal-apps/async", payload.model_dump(mode="json")) + return ModalAppResponse(**response) + + def get_modal_app(self, app_id: int) -> ModalAppResponse: + """Get a Modal app by ID.""" + response = self._get(f"/modal-apps/{app_id}") + return ModalAppResponse(**response) + + def get_modal_apps(self) -> list[ModalAppResponse]: + """List all Modal apps for the current user.""" + response = self._get("/modal-apps/") + return [ModalAppResponse(**app) for app in response] + + def patch_modal_app( + self, app_id: int, payload: ModalAppPatchRequest + ) -> ModalAppResponse: + """Update a Modal app. Triggers redeployment if file_contents changed.""" + response = self._call( + requests.patch, + f"/modal-apps/async/{app_id}", + payload.model_dump(mode="json", exclude_none=True), + ) + return ModalAppResponse(**response) + + def delete_modal_app(self, app_id: int) -> dict: + """Delete a Modal app.""" + return self._delete(f"/modal-apps/{app_id}", {}) + + def poll_modal_app_deployment( + self, app_id: int, timeout: float = 300.0, poll_interval: float = 2.0 + ) -> ModalAppResponse: + """Poll a Modal app until deployment completes or fails.""" + start_time = time.time() + while True: + app = self.get_modal_app(app_id) + if app.deploy_status in ("success", "failed", None): + return app + if time.time() - start_time > timeout: + raise TimeoutError( + f"Modal app deployment timed out after {timeout}s. " + f"Current status: {app.deploy_status}" + ) + time.sleep(poll_interval) + + def get_modal_function(self, function_id: int) -> ModalFunctionResponse: + """Get a Modal function by ID.""" + response = self._get(f"/modal-functions/{function_id}") + return ModalFunctionResponse(**response) + + def get_modal_functions( + self, + ids: list[int] | None = None, + tags: list[str] | None = None, + authors: list[str] | None = None, + owner_uuid: str | None = None, + draft: bool | None = None, + year: str | None = None, + limit: int = 50, + ) -> list[ModalFunctionResponse]: + """List Modal functions with optional filters.""" + params = { + "id": ids, + "tags": tags, + "authors": authors, + "owner_uuid": owner_uuid, + "draft": draft, + "year": year, + "limit": limit, + } + params = {k: v for k, v in params.items() if v is not None} + response = self._get("/modal-functions", params=params) + return [ModalFunctionResponse(**fn) for fn in response] + + def patch_modal_function( + self, function_id: int, payload: ModalFunctionPatchRequest + ) -> ModalFunctionResponse: + """Update a Modal function's metadata.""" + response = self._call( + requests.patch, + f"/modal-functions/{function_id}", + payload.model_dump(mode="json", exclude_none=True), + ) + return ModalFunctionResponse(**response) + + def create_hpc_endpoint( + self, payload: HpcEndpointCreateRequest + ) -> HpcEndpointResponse: + """Create a new HPC endpoint.""" + response = self._post("/hpc/endpoints", payload.model_dump(mode="json")) + return HpcEndpointResponse(**response) + + def get_hpc_endpoint(self, endpoint_id: int) -> HpcEndpointResponse: + """Get an HPC endpoint by ID.""" + response = self._get(f"/hpc/endpoints/{endpoint_id}") + return HpcEndpointResponse(**response) + + def get_hpc_endpoints(self, limit: int = 50) -> list[HpcEndpointResponse]: + """List all HPC endpoints.""" + response = self._get("/hpc/endpoints", params={"limit": limit}) + return [HpcEndpointResponse(**ep) for ep in response] + + def patch_hpc_endpoint( + self, endpoint_id: int, payload: HpcEndpointPatchRequest + ) -> HpcEndpointResponse: + """Update an HPC endpoint.""" + response = self._call( + requests.patch, + f"/hpc/endpoints/{endpoint_id}", + payload.model_dump(mode="json", exclude_none=True), + ) + return HpcEndpointResponse(**response) + + def delete_hpc_endpoint(self, endpoint_id: int) -> dict: + """Delete an HPC endpoint.""" + return self._delete(f"/hpc/endpoints/{endpoint_id}", {}) + + def create_hpc_function( + self, payload: HpcFunctionCreateRequest + ) -> HpcFunctionResponse: + """Create a new HPC function.""" + response = self._post("/hpc/functions", payload.model_dump(mode="json")) + return HpcFunctionResponse(**response) + + def get_hpc_function(self, function_id: int) -> HpcFunctionResponse: + """Get an HPC function by ID.""" + response = self._get(f"/hpc/functions/{function_id}") + return HpcFunctionResponse(**response) + + def get_hpc_functions(self) -> list[HpcFunctionResponse]: + """List HPC functions for the current user.""" + response = self._get("/hpc/functions") + return [HpcFunctionResponse(**fn) for fn in response] + + def patch_hpc_function( + self, function_id: int, payload: HpcFunctionPatchRequest + ) -> HpcFunctionResponse: + """Update an HPC function.""" + response = self._call( + requests.patch, + f"/hpc/functions/{function_id}", + payload.model_dump(mode="json", exclude_none=True), + ) + return HpcFunctionResponse(**response) + + def delete_hpc_function(self, function_id: int) -> dict: + """Delete an HPC function.""" + return self._delete(f"/hpc/functions/{function_id}", {}) diff --git a/garden_ai/gardens.py b/garden_ai/gardens.py index d53e465c..51be69da 100644 --- a/garden_ai/gardens.py +++ b/garden_ai/gardens.py @@ -145,7 +145,9 @@ def _repr_html_(self) -> str: classes_data.append( { "Class": cls.class_name, - "Method": method.metadata.function_name.split(".")[-1], + "Method": (method.metadata.function_name or "").split(".")[ + -1 + ], "Title": str(method.metadata.title), "Authors": ", ".join(method.metadata.authors), "DOI": str(method.metadata.doi or ""), @@ -188,7 +190,7 @@ def _from_nested_metadata(cls, data: dict, client: GardenClient | None = None): metadata.modal_function_ids += [fn_metadata.id] # Check if this is a class method - if "." in fn_metadata.function_name: + if fn_metadata.function_name and "." in fn_metadata.function_name: class_name, _ = fn_metadata.function_name.split(".", 1) if class_name not in class_methods: class_methods[class_name] = [] diff --git a/garden_ai/hpc/functions.py b/garden_ai/hpc/functions.py index 44c9649a..1aa3846e 100644 --- a/garden_ai/hpc/functions.py +++ b/garden_ai/hpc/functions.py @@ -33,12 +33,28 @@ def __init__( ): self.metadata = metadata self._client = client - - self._groundhog_function = load_function_from_source( - self.metadata.function_text, self.metadata.function_name - ) + self._groundhog_function_cached = None self.endpoints = metadata.available_endpoints + @property + def _groundhog_function(self): + """Lazily load the groundhog function from source when first accessed. + + If function_text is not available (e.g., from a list response), fetches + the full function data from the backend first. + """ + if self._groundhog_function_cached is None: + if self.metadata.function_text is None: + # Fetch full function data from backend + full_metadata = self.client.backend_client.get_hpc_function( + self.metadata.id + ) + self.metadata = HpcFunctionMetadata(**full_metadata.model_dump()) + self._groundhog_function_cached = load_function_from_source( + self.metadata.function_text, self.metadata.function_name + ) + return self._groundhog_function_cached + @property def client(self) -> GardenClient: return self._client or self._get_garden_client() diff --git a/garden_ai/modal/classes.py b/garden_ai/modal/classes.py index b65fbbc9..654cdc43 100644 --- a/garden_ai/modal/classes.py +++ b/garden_ai/modal/classes.py @@ -25,7 +25,8 @@ def __init__( self.class_name = class_name # Create method lookup internally self._methods = { - method.metadata.function_name.split(".")[-1]: method for method in methods + (method.metadata.function_name or "").split(".")[-1]: method + for method in methods } def __getattr__(self, method_name: str) -> Any: diff --git a/garden_ai/schemas/__init__.py b/garden_ai/schemas/__init__.py index 3aae7e0c..7aa45e78 100644 --- a/garden_ai/schemas/__init__.py +++ b/garden_ai/schemas/__init__.py @@ -3,3 +3,32 @@ ModalInvocationRequest, ModalInvocationResponse, ) +from .modal_app import ( # noqa + AsyncModalJobStatus, + ModalAppCreateRequest, + ModalAppPatchRequest, + ModalAppResponse, + ModalFunctionCreateMetadata, + ModalFunctionPatchRequest, + ModalFunctionResponse, +) +from .garden import ( # noqa + GardenCreateRequest, + GardenMetadata, + GardenPatchRequest, +) +from .groundhog import ( # noqa + HpcEndpointCreateRequest, + HpcEndpointPatchRequest, + HpcEndpointResponse, + HpcFunctionCreateRequest, + HpcFunctionPatchRequest, + HpcFunctionResponse, +) +from .entrypoint import ( # noqa + DatasetMetadata, + ModelMetadata, + NotebookMetadata, + PaperMetadata, + RepositoryMetadata, +) diff --git a/garden_ai/schemas/base.py b/garden_ai/schemas/base.py new file mode 100644 index 00000000..22b911bd --- /dev/null +++ b/garden_ai/schemas/base.py @@ -0,0 +1,70 @@ +"""Base schemas for common function and endpoint metadata.""" + +from pydantic import BaseModel, Field + +from .entrypoint import ( + DatasetMetadata, + ModelMetadata, + NotebookMetadata, + PaperMetadata, + RepositoryMetadata, +) +from .schema_utils import UniqueList + + +class RelatedMetadata(BaseModel): + """Related resources (models, papers, datasets, etc.) for a function.""" + + models: list[ModelMetadata] = Field(default_factory=list) + repositories: list[RepositoryMetadata] = Field(default_factory=list) + papers: list[PaperMetadata] = Field(default_factory=list) + datasets: list[DatasetMetadata] = Field(default_factory=list) + notebooks: list[NotebookMetadata] = Field(default_factory=list) + + +class BaseFunctionMetadata(BaseModel): + """Core metadata fields shared by all function types (Modal and HPC).""" + + function_name: str + title: str + description: str | None = None + year: str + authors: UniqueList[str] = Field(default_factory=list) + tags: UniqueList[str] = Field(default_factory=list) + function_text: str + requirements: list[str] = Field(default_factory=list) + test_functions: list[str] = Field(default_factory=list) + + +class BaseFunctionCreateRequest(BaseFunctionMetadata, RelatedMetadata): + """Base for function creation requests with metadata and related resources.""" + + pass + + +class BaseFunctionPatchRequest(BaseModel): + """Base for function patch requests - all fields optional.""" + + function_name: str | None = None + title: str | None = None + description: str | None = None + year: str | None = None + function_text: str | None = None + authors: UniqueList[str] | None = None + tags: UniqueList[str] | None = None + requirements: list[str] | None = None + test_functions: list[str] | None = None + + # Related metadata + models: list[ModelMetadata] | None = None + repositories: list[RepositoryMetadata] | None = None + papers: list[PaperMetadata] | None = None + datasets: list[DatasetMetadata] | None = None + notebooks: list[NotebookMetadata] | None = None + + +class HpcEndpointBase(BaseModel): + """Base fields for HPC endpoint schemas.""" + + name: str + gcmu_id: str | None = None # Globus Compute endpoint UUID diff --git a/garden_ai/schemas/entrypoint.py b/garden_ai/schemas/entrypoint.py index d3e88574..41147474 100644 --- a/garden_ai/schemas/entrypoint.py +++ b/garden_ai/schemas/entrypoint.py @@ -85,3 +85,17 @@ class ModelMetadata(BaseModel, protected_namespaces=()): model_identifier: str model_repository: str model_version: str | None = None + + +class NotebookMetadata(BaseModel): + """Metadata for a Jupyter notebook associated with a function. + + Attributes: + title: The title of the notebook. + description: A brief description of the notebook. + url: The URL where the notebook can be accessed. + """ + + title: str + description: str | None = None + url: Url diff --git a/garden_ai/schemas/garden.py b/garden_ai/schemas/garden.py index 21f06d3a..c149c1e7 100644 --- a/garden_ai/schemas/garden.py +++ b/garden_ai/schemas/garden.py @@ -47,3 +47,43 @@ class GardenMetadata(BaseModel): owner_identity_id: UUID | None = None id: int | None = None + + +class GardenCreateRequest(BaseModel): + """Request schema for creating a new Garden.""" + + title: str + authors: UniqueList[str] + description: str | None = None + + contributors: UniqueList[str] = Field(default_factory=list) + tags: UniqueList[str] = Field(default_factory=list) + year: str = Field(default_factory=lambda: str(datetime.now().year)) + version: str = "0.0.1" + language: str = "en" + publisher: str = "Garden-AI" + + modal_function_ids: UniqueList[int] = Field(default_factory=list) + hpc_function_ids: UniqueList[int] = Field(default_factory=list) + + owner_identity_id: UUID | None = None + + +class GardenPatchRequest(BaseModel): + """Request schema for updating an existing Garden. + + All fields are optional. Only provided fields will be updated. + """ + + title: str | None = None + authors: UniqueList[str] | None = None + contributors: UniqueList[str] | None = None + description: str | None = None + publisher: str | None = None + year: str | None = None + language: str | None = None + tags: UniqueList[str] | None = None + version: str | None = None + + modal_function_ids: UniqueList[int] | None = None + hpc_function_ids: UniqueList[int] | None = None diff --git a/garden_ai/schemas/groundhog.py b/garden_ai/schemas/groundhog.py new file mode 100644 index 00000000..77f04724 --- /dev/null +++ b/garden_ai/schemas/groundhog.py @@ -0,0 +1,73 @@ +"""Schemas for Groundhog (HPC) endpoints and functions.""" + +from uuid import UUID + +from pydantic import BaseModel, Field + +from .base import ( + BaseFunctionCreateRequest, + BaseFunctionPatchRequest, + HpcEndpointBase, + RelatedMetadata, +) + + +class HpcEndpointCreateRequest(HpcEndpointBase): + """Request schema for creating an HPC endpoint.""" + + pass + + +class HpcEndpointResponse(HpcEndpointBase): + """Response schema for an HPC endpoint.""" + + id: int + owner: str | None = None + owner_identity_id: UUID | None = None + + +class HpcEndpointPatchRequest(BaseModel): + """Request schema for patching an HPC endpoint.""" + + name: str | None = None + gcmu_id: str | None = None + + +class HpcEndpointInfo(HpcEndpointBase): + """Basic endpoint info returned with HPC functions.""" + + pass + + +class HpcFunctionCreateRequest(BaseFunctionCreateRequest): + """Request schema for creating an HPC function.""" + + endpoint_ids: list[int] + + +class HpcFunctionResponse(RelatedMetadata): + """Response schema for an HPC function.""" + + id: int + function_name: str + function_text: str | None = None + + # Metadata + title: str | None = None + description: str | None = None + year: str | None = None + authors: list[str] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) + requirements: list[str] = Field(default_factory=list) + is_archived: bool = False + + # Endpoint info + available_endpoints: list[HpcEndpointInfo] = Field(default_factory=list) + num_invocations: int = 0 + + +class HpcFunctionPatchRequest(BaseFunctionPatchRequest): + """Request schema for patching an HPC function.""" + + endpoint_ids: list[int] | None = None + is_archived: bool | None = None diff --git a/garden_ai/schemas/hpc.py b/garden_ai/schemas/hpc.py index 12417f02..1e1d2f5e 100644 --- a/garden_ai/schemas/hpc.py +++ b/garden_ai/schemas/hpc.py @@ -8,7 +8,7 @@ class HpcFunctionMetadata(BaseModel): id: int function_name: str - function_text: str + function_text: str | None = None title: str | None = None description: str | None = None available_endpoints: list[dict[str, str]] = Field(default_factory=list) diff --git a/garden_ai/schemas/modal.py b/garden_ai/schemas/modal.py index 8bb25129..4c91eec3 100644 --- a/garden_ai/schemas/modal.py +++ b/garden_ai/schemas/modal.py @@ -23,8 +23,8 @@ class ModalFunctionMetadata(BaseModel): # Function Metadata is_archived: bool = False - function_name: str - function_text: str + function_name: str | None = None + function_text: str | None = None authors: UniqueList[str] = Field(default_factory=list) tags: UniqueList[str] = Field(default_factory=list) diff --git a/garden_ai/schemas/modal_app.py b/garden_ai/schemas/modal_app.py new file mode 100644 index 00000000..8684d7df --- /dev/null +++ b/garden_ai/schemas/modal_app.py @@ -0,0 +1,147 @@ +"""Schemas for Modal App CRUD operations.""" + +from enum import Enum +from uuid import UUID + +from pydantic import BaseModel, Field, computed_field + +from .base import ( + BaseFunctionCreateRequest, + BaseFunctionMetadata, + BaseFunctionPatchRequest, + RelatedMetadata, +) +from .schema_utils import UniqueList + + +class ModalFileMetadataRequest(BaseModel): + """Request schema for parsing a Modal file.""" + + file_contents: str + + +class ParsedModalFunctionMetadata(BaseFunctionMetadata): + """Parsed function metadata returned from backend file parsing.""" + + pass + + +class ModalFileMetadataResponse(BaseModel): + """Response schema from parsing a Modal file.""" + + app_name: str + original_app_name: str | None = None + modal_functions: list[ParsedModalFunctionMetadata] = Field(default_factory=list) + file_contents: str + requirements: list[str] = Field(default_factory=list) + conda_requirements: list[str] = Field(default_factory=list) + base_image_name: str + + @computed_field # type: ignore[prop-decorator] + @property + def modal_function_names(self) -> list[str]: + return [mf.function_name for mf in self.modal_functions] + + +class ModalFunctionCreateMetadata(BaseFunctionCreateRequest): + """Metadata for a Modal function when creating a Modal App.""" + + example_usage: str = "" + + +class ModalAppCreateRequest(BaseModel): + """Request schema for creating a Modal App.""" + + app_name: str + file_contents: str + base_image_name: str + requirements: list[str] = Field(default_factory=list) + conda_requirements: list[str] = Field(default_factory=list) + modal_functions: list[ModalFunctionCreateMetadata] = Field(default_factory=list) + owner_identity_id: str | None = None + + @computed_field # type: ignore[prop-decorator] + @property + def modal_function_names(self) -> list[str]: + return [mf.function_name for mf in self.modal_functions] + + +class AsyncModalJobStatus(str, Enum): + """Status of an async Modal deployment job.""" + + PENDING = "pending" + RUNNING = "running" + SUCCESS = "success" + FAILED = "failed" + DONE = "done" + + +class ModalFunctionResponse(RelatedMetadata): + """Response schema for a Modal function.""" + + id: int + modal_app_id: int + function_name: str + doi: str | None = None + + title: str + description: str | None = None + year: str + authors: UniqueList[str] = Field(default_factory=list) + tags: UniqueList[str] = Field(default_factory=list) + function_text: str | None = None + example_usage: str = "" + requirements: list[str] = Field(default_factory=list) + is_archived: bool = False + + hardware_spec: dict = Field(default_factory=dict) + num_invocations: int = 0 + + owner: str = "" + owner_identity_id: UUID | None = None + + +class ModalAppResponse(BaseModel): + """Response schema for a Modal App.""" + + id: int + app_name: str + original_app_name: str | None = None + base_image_name: str + requirements: list[str] = Field(default_factory=list) + conda_requirements: list[str] = Field(default_factory=list) + file_contents: str | None = None + modal_functions: list[ModalFunctionResponse] = Field(default_factory=list) + owner_identity_id: UUID + + deploy_status: AsyncModalJobStatus | None = None + deploy_error: str | None = None + suggested_fix: str | None = None + deployment_output: str | None = None + + @computed_field # type: ignore[prop-decorator] + @property + def modal_function_ids(self) -> list[int]: + return [mf.id for mf in self.modal_functions] + + @computed_field # type: ignore[prop-decorator] + @property + def modal_function_names(self) -> list[str]: + return [mf.function_name for mf in self.modal_functions] + + +class ModalAppPatchRequest(BaseModel): + """Request schema for patching a Modal App.""" + + base_image_name: str | None = None + requirements: list[str] | None = None + conda_requirements: list[str] | None = None + file_contents: str | None = None + + +class ModalFunctionPatchRequest(BaseFunctionPatchRequest): + """Request schema for patching a Modal function.""" + + doi: str | None = None + is_archived: bool | None = None + example_usage: str | None = None diff --git a/mkdocs.yml b/mkdocs.yml index ba9ec791..8e4d5794 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,6 +4,7 @@ edit_uri: edit/main/docs/ nav: - Overview: index.md - Publishing with Modal: user_guide/publishing/modal-publishing.md + - CLI Reference: CLI.md - API Reference: api-docs.md - Developer Guide: developer_guide/contributing.md theme: diff --git a/tests/app/test_groundhog.py b/tests/app/test_groundhog.py new file mode 100644 index 00000000..ab08a0b7 --- /dev/null +++ b/tests/app/test_groundhog.py @@ -0,0 +1,93 @@ +"""Tests for groundhog CLI parsing functions.""" + +import tempfile +from pathlib import Path + +from garden_ai.app.groundhog import _extract_function_from_file + +# Example script with @hog.function +SCRIPT_WITH_FUNCTION = ''' +import groundhog_hpc as hog + +@hog.function(endpoint="anvil") +def compute_mean(numbers): + """Calculate the mean of numbers.""" + import numpy as np + return float(np.mean(numbers)) + +@hog.harness() +def main(): + result = compute_mean.remote([1, 2, 3]) + print(result) +''' + +# Example script with @hog.method in a class +SCRIPT_WITH_METHOD = ''' +import groundhog_hpc as hog + +class Statistics: + @hog.method(endpoint="anvil") + def compute_mean(numbers): + """Calculate mean using numpy.""" + import numpy as np + return float(np.mean(numbers)) + + @hog.method(endpoint="anvil") + def compute_std(numbers): + """Calculate standard deviation.""" + import numpy as np + return float(np.std(numbers)) +''' + +# Script with no hog decorators +SCRIPT_NO_DECORATORS = ''' +def helper_function(): + """Just a helper.""" + pass +''' + + +def _write_temp_script(content: str, name: str = "script") -> Path: + """Write content to a temp file and return the path.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", delete=False, prefix=f"{name}_" + ) as f: + f.write(content) + return Path(f.name) + + +def test_extracts_hog_function(): + """@hog.function decorated functions are extracted with name and docstring.""" + path = _write_temp_script(SCRIPT_WITH_FUNCTION) + result = _extract_function_from_file(path) + + assert result["function_name"] == "compute_mean" + assert result["docstring"] == "Calculate the mean of numbers." + assert "import groundhog_hpc" in result["function_text"] + + +def test_extracts_hog_method_with_class_name(): + """@hog.method decorated methods include ClassName.method_name format.""" + path = _write_temp_script(SCRIPT_WITH_METHOD) + result = _extract_function_from_file(path) + + assert result["function_name"] == "Statistics.compute_mean" + assert result["docstring"] == "Calculate mean using numpy." + + +def test_ignores_harness_decorator(): + """@hog.harness is for orchestration, not remote execution - should be skipped.""" + path = _write_temp_script(SCRIPT_WITH_FUNCTION) + result = _extract_function_from_file(path) + + # Should find compute_mean, not main (the harness) + assert result["function_name"] == "compute_mean" + + +def test_falls_back_to_filename(): + """Files without hog decorators fall back to filename.""" + path = _write_temp_script(SCRIPT_NO_DECORATORS, name="my_model") + result = _extract_function_from_file(path) + + assert result["function_name"] == path.stem + assert result["docstring"] == ""