diff --git a/pyproject.toml b/pyproject.toml index 3c77b79..8e5965b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,15 @@ classifiers = [ dependencies = ["requests>=2.25.1,<3", "dataclasses_json>=0.6.7"] +[project.optional-dependencies] +cli = [ + "typer>=0.12.0,<1", + "rich>=13.0.0,<14", +] + +[project.scripts] +verda-cli = "verda.cli.main:app" + [dependency-groups] dev = [ "pytest-cov>=2.10.1,<3", diff --git a/uv.lock b/uv.lock index 585e558..0247398 100644 --- a/uv.lock +++ b/uv.lock @@ -86,6 +86,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446, upload-time = "2024-10-09T07:40:19.383Z" }, ] +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -218,6 +230,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + [[package]] name = "marshmallow" version = "3.26.1" @@ -230,6 +254,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/75/51952c7b2d3873b44a0028b1bd26a25078c18f92f256608e8d1dc61b39fd/marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c", size = 50878, upload-time = "2025-02-03T15:32:22.295Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mypy-extensions" version = "1.1.0" @@ -392,6 +425,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/4c/cc276ce57e572c102d9542d383b2cfd551276581dc60004cb94fe8774c11/responses-0.25.8-py3-none-any.whl", hash = "sha256:0c710af92def29c8352ceadff0c3fe340ace27cf5af1bbe46fb71275bcd2831c", size = 34769, upload-time = "2025-08-08T19:01:45.018Z" }, ] +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, +] + [[package]] name = "ruff" version = "0.14.2" @@ -418,6 +465,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "toml" version = "0.10.2" @@ -476,6 +532,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ] +[[package]] +name = "typer" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -516,6 +587,12 @@ dependencies = [ { name = "requests" }, ] +[package.optional-dependencies] +cli = [ + { name = "rich" }, + { name = "typer" }, +] + [package.dev-dependencies] dev = [ { name = "pytest" }, @@ -530,7 +607,10 @@ dev = [ requires-dist = [ { name = "dataclasses-json", specifier = ">=0.6.7" }, { name = "requests", specifier = ">=2.25.1,<3" }, + { name = "rich", marker = "extra == 'cli'", specifier = ">=13.0.0,<14" }, + { name = "typer", marker = "extra == 'cli'", specifier = ">=0.12.0,<1" }, ] +provides-extras = ["cli"] [package.metadata.requires-dev] dev = [ diff --git a/verda/cli/__init__.py b/verda/cli/__init__.py new file mode 100644 index 0000000..96f4c70 --- /dev/null +++ b/verda/cli/__init__.py @@ -0,0 +1 @@ +"""Verda CLI module.""" diff --git a/verda/cli/commands/__init__.py b/verda/cli/commands/__init__.py new file mode 100644 index 0000000..9937ab9 --- /dev/null +++ b/verda/cli/commands/__init__.py @@ -0,0 +1 @@ +"""CLI command modules.""" diff --git a/verda/cli/commands/balance.py b/verda/cli/commands/balance.py new file mode 100644 index 0000000..ffef7da --- /dev/null +++ b/verda/cli/commands/balance.py @@ -0,0 +1,24 @@ +"""Commands for checking account balance.""" + +import typer + +from verda.cli import main as cli_main +from verda.cli.utils.client import get_client +from verda.cli.utils.errors import handle_api_errors +from verda.cli.utils.output import console, output_json, spinner + +app = typer.Typer(no_args_is_help=True) + + +@app.command('get') +@handle_api_errors +def get_balance() -> None: + """Get account balance.""" + client = get_client() + with spinner('Fetching balance...'): + balance = client.balance.get() + + if cli_main.state['json_output']: + output_json({'amount': balance.amount, 'currency': balance.currency}) + else: + console.print(f'Balance: [bold green]{balance.currency} {balance.amount:.2f}[/bold green]') diff --git a/verda/cli/commands/clusters.py b/verda/cli/commands/clusters.py new file mode 100644 index 0000000..aaba63e --- /dev/null +++ b/verda/cli/commands/clusters.py @@ -0,0 +1,236 @@ +"""Commands for managing compute clusters.""" + +from typing import Annotated + +import typer + +from verda.cli import main as cli_main +from verda.cli.utils.client import get_client +from verda.cli.utils.errors import handle_api_errors +from verda.cli.utils.output import ( + console, + output_json, + output_single, + output_table, + spinner, + success, +) +from verda.constants import ClusterStatus, Locations + +app = typer.Typer(no_args_is_help=True) + + +def status_color(status: str) -> str: + """Format status with color.""" + colors = { + 'running': '[green]running[/green]', + 'provisioning': '[yellow]provisioning[/yellow]', + 'ordered': '[yellow]ordered[/yellow]', + 'discontinued': '[red]discontinued[/red]', + 'error': '[red]error[/red]', + } + return colors.get(status, status) + + +CLUSTER_COLUMNS = [ + ('ID', 'id', None), + ('Hostname', 'hostname', None), + ('Type', 'cluster_type', None), + ('Status', 'status', status_color), + ('Location', 'location', None), + ('IP', 'ip', lambda x: x or '-'), + ('Workers', 'worker_nodes', lambda x: str(len(x)) if x else '0'), +] + + +@app.command('list') +@handle_api_errors +def list_clusters( + status: Annotated[ + str | None, + typer.Option('--status', '-s', help='Filter by status'), + ] = None, +) -> None: + """List all clusters.""" + client = get_client() + with spinner('Fetching clusters...'): + clusters = client.clusters.get(status=status) + + if cli_main.state['json_output']: + output_json(clusters) + else: + output_table(clusters, CLUSTER_COLUMNS, title='Clusters') + + +@app.command('get') +@handle_api_errors +def get_cluster( + cluster_id: Annotated[str, typer.Argument(help='Cluster ID')], +) -> None: + """Get cluster details by ID.""" + client = get_client() + with spinner('Fetching cluster...'): + cluster = client.clusters.get_by_id(cluster_id) + + if cli_main.state['json_output']: + output_json(cluster) + else: + fields = [ + ('ID', 'id', None), + ('Hostname', 'hostname', None), + ('Description', 'description', None), + ('Type', 'cluster_type', None), + ('Status', 'status', status_color), + ('Location', 'location', None), + ('IP', 'ip', None), + ('Image', 'image', None), + ('Workers', 'worker_nodes', lambda x: str(len(x)) if x else '0'), + ('Shared Volumes', 'shared_volumes', lambda x: str(len(x)) if x else '0'), + ('Created', 'created_at', None), + ] + output_single(cluster, fields, title=f'Cluster: {cluster.hostname}') + + +@app.command('create') +@handle_api_errors +def create_cluster( + cluster_type: Annotated[ + str, + typer.Option('--type', '-t', help='Cluster type'), + ], + image: Annotated[ + str, + typer.Option('--image', '-i', help='Image name'), + ], + hostname: Annotated[ + str, + typer.Option('--hostname', '-n', help='Cluster hostname'), + ], + description: Annotated[ + str, + typer.Option('--description', '-d', help='Cluster description'), + ] = '', + ssh_key_ids: Annotated[ + list[str] | None, + typer.Option('--ssh-key', '-k', help='SSH key IDs'), + ] = None, + location: Annotated[ + str, + typer.Option('--location', '-l', help='Datacenter location'), + ] = Locations.FIN_03, + startup_script_id: Annotated[ + str | None, + typer.Option('--startup-script', help='Startup script ID'), + ] = None, + shared_volume_name: Annotated[ + str | None, + typer.Option('--shared-volume-name', help='Shared volume name'), + ] = None, + shared_volume_size: Annotated[ + int | None, + typer.Option('--shared-volume-size', help='Shared volume size in GB'), + ] = None, + no_wait: Annotated[ + bool, + typer.Option('--no-wait', help="Don't wait for cluster to provision"), + ] = False, +) -> None: + """Create a new cluster.""" + client = get_client() + + wait_status = None if no_wait else ClusterStatus.PROVISIONING + + with spinner('Creating cluster...'): + cluster = client.clusters.create( + cluster_type=cluster_type, + image=image, + hostname=hostname, + description=description, + ssh_key_ids=ssh_key_ids or [], + location=location, + startup_script_id=startup_script_id, + shared_volume_name=shared_volume_name, + shared_volume_size=shared_volume_size, + wait_for_status=wait_status, + ) + + if cli_main.state['json_output']: + output_json(cluster) + else: + success(f"Cluster '{cluster.hostname}' created with ID: {cluster.id}") + + +@app.command('delete') +@handle_api_errors +def delete_cluster( + cluster_id: Annotated[str, typer.Argument(help='Cluster ID')], + force: Annotated[ + bool, + typer.Option('--force', '-f', help='Skip confirmation'), + ] = False, +) -> None: + """Delete a cluster.""" + if not force: + confirm = typer.confirm(f'Are you sure you want to delete cluster {cluster_id}?') + if not confirm: + raise typer.Abort() + + client = get_client() + with spinner('Deleting cluster...'): + client.clusters.delete(cluster_id) + success(f'Cluster {cluster_id} deletion initiated') + + +@app.command('availability') +@handle_api_errors +def check_availability( + cluster_type: Annotated[ + str | None, + typer.Argument(help='Cluster type to check (omit to list all)'), + ] = None, + location: Annotated[ + str | None, + typer.Option('--location', '-l', help='Location code'), + ] = None, +) -> None: + """Check cluster type availability.""" + client = get_client() + + if cluster_type: + with spinner('Checking availability...'): + available = client.clusters.is_available(cluster_type, location_code=location) + if cli_main.state['json_output']: + output_json({'cluster_type': cluster_type, 'available': available}) + else: + status = '[green]Available[/green]' if available else '[red]Not Available[/red]' + typer.echo(f'{cluster_type}: {status}') + else: + with spinner('Fetching availabilities...'): + availabilities = client.clusters.get_availabilities(location_code=location) + if cli_main.state['json_output']: + output_json(availabilities) + else: + console.print('Available Cluster Types:') + for item in availabilities: + console.print(f' {item}') + + +@app.command('images') +@handle_api_errors +def list_cluster_images( + cluster_type: Annotated[ + str | None, + typer.Option('--type', '-t', help='Cluster type'), + ] = None, +) -> None: + """List available cluster images.""" + client = get_client() + with spinner('Fetching images...'): + images = client.clusters.get_cluster_images(cluster_type=cluster_type) + + if cli_main.state['json_output']: + output_json(images) + else: + console.print('Available Cluster Images:') + for img in images: + console.print(f' {img}') diff --git a/verda/cli/commands/images.py b/verda/cli/commands/images.py new file mode 100644 index 0000000..054b9d3 --- /dev/null +++ b/verda/cli/commands/images.py @@ -0,0 +1,33 @@ +"""Commands for listing available images.""" + +import typer + +from verda.cli import main as cli_main +from verda.cli.utils.client import get_client +from verda.cli.utils.errors import handle_api_errors +from verda.cli.utils.output import output_json, output_table, spinner + +app = typer.Typer(no_args_is_help=True) + + +@app.command('list') +@handle_api_errors +def list_images() -> None: + """List available OS images.""" + client = get_client() + with spinner('Fetching images...'): + images = client.images.get() + + if cli_main.state['json_output']: + output_json(images) + else: + columns = [ + ('Image Type', 'image_type', None), + ('Name', 'name', None), + ( + 'Details', + 'details', + lambda x: ', '.join(x[:2]) + ('...' if len(x) > 2 else '') if x else '', + ), + ] + output_table(images, columns, title='Available Images') diff --git a/verda/cli/commands/instance_types.py b/verda/cli/commands/instance_types.py new file mode 100644 index 0000000..81f8208 --- /dev/null +++ b/verda/cli/commands/instance_types.py @@ -0,0 +1,46 @@ +"""Commands for listing available instance types.""" + +import typer + +from verda.cli import main as cli_main +from verda.cli.utils.client import get_client +from verda.cli.utils.errors import handle_api_errors +from verda.cli.utils.output import output_json, output_table, spinner + +app = typer.Typer(no_args_is_help=True) + + +def format_gpu(gpu: dict) -> str: + """Format GPU details.""" + if not gpu: + return '-' + return gpu.get('description', '-') + + +def format_memory(memory: dict) -> str: + """Format memory details.""" + if not memory: + return '-' + return memory.get('description', '-') + + +@app.command('list') +@handle_api_errors +def list_instance_types() -> None: + """List available instance types with pricing.""" + client = get_client() + with spinner('Fetching instance types...'): + types = client.instance_types.get() + + if cli_main.state['json_output']: + output_json(types) + else: + columns = [ + ('Type', 'instance_type', None), + ('Description', 'description', None), + ('Price/hr', 'price_per_hour', lambda x: f'${x:.4f}'), + ('Spot Price/hr', 'spot_price_per_hour', lambda x: f'${x:.4f}'), + ('GPU', 'gpu', format_gpu), + ('Memory', 'memory', format_memory), + ] + output_table(types, columns, title='Instance Types') diff --git a/verda/cli/commands/instances.py b/verda/cli/commands/instances.py new file mode 100644 index 0000000..0744d6c --- /dev/null +++ b/verda/cli/commands/instances.py @@ -0,0 +1,291 @@ +"""Commands for managing compute instances.""" + +from typing import Annotated + +import typer + +from verda.cli import main as cli_main +from verda.cli.utils.client import get_client +from verda.cli.utils.errors import handle_api_errors +from verda.cli.utils.output import output_json, output_single, output_table, spinner, success +from verda.constants import Actions, Locations + +app = typer.Typer(no_args_is_help=True) + + +def status_color(status: str) -> str: + """Format status with color.""" + colors = { + 'running': '[green]running[/green]', + 'offline': '[red]offline[/red]', + 'provisioning': '[yellow]provisioning[/yellow]', + 'ordered': '[yellow]ordered[/yellow]', + 'hibernating': '[blue]hibernating[/blue]', + 'starting_hibernation': '[blue]starting_hibernation[/blue]', + 'restoring': '[yellow]restoring[/yellow]', + 'error': '[red]error[/red]', + } + return colors.get(status, status) + + +INSTANCE_COLUMNS = [ + ('ID', 'id', None), + ('Type', 'instance_type', None), + ('Hostname', 'hostname', None), + ('Status', 'status', status_color), + ('IP', 'ip', None), + ('Location', 'location', None), + ('Price/hr', 'price_per_hour', lambda x: f'${x:.4f}'), +] + + +@app.command('list') +@handle_api_errors +def list_instances( + status: Annotated[ + str | None, + typer.Option('--status', '-s', help='Filter by status'), + ] = None, +) -> None: + """List all instances.""" + client = get_client() + with spinner('Fetching instances...'): + instances = client.instances.get(status=status) + + if cli_main.state['json_output']: + output_json(instances) + else: + output_table(instances, INSTANCE_COLUMNS, title='Instances') + + +@app.command('get') +@handle_api_errors +def get_instance( + instance_id: Annotated[str, typer.Argument(help='Instance ID')], +) -> None: + """Get instance details by ID.""" + client = get_client() + with spinner('Fetching instance...'): + instance = client.instances.get_by_id(instance_id) + + if cli_main.state['json_output']: + output_json(instance) + else: + fields = [ + ('ID', 'id', None), + ('Type', 'instance_type', None), + ('Hostname', 'hostname', None), + ('Description', 'description', None), + ('Status', 'status', status_color), + ('IP', 'ip', None), + ('Location', 'location', None), + ('Image', 'image', None), + ('Price/hr', 'price_per_hour', lambda x: f'${x:.4f}'), + ('Created', 'created_at', None), + ('Spot', 'is_spot', None), + ('Contract', 'contract', None), + ('OS Volume ID', 'os_volume_id', None), + ] + output_single(instance, fields, title=f'Instance: {instance.hostname}') + + +@app.command('create') +@handle_api_errors +def create_instance( + instance_type: Annotated[ + str, + typer.Option('--type', '-t', help='Instance type (e.g., 1V100.6V)'), + ], + image: Annotated[ + str, + typer.Option('--image', '-i', help='Image name (e.g., ubuntu-24.04-cuda-12.8)'), + ], + hostname: Annotated[ + str, + typer.Option('--hostname', '-n', help='Instance hostname'), + ], + description: Annotated[ + str, + typer.Option('--description', '-d', help='Instance description'), + ] = '', + ssh_key_ids: Annotated[ + list[str] | None, + typer.Option('--ssh-key', '-k', help='SSH key IDs (can specify multiple)'), + ] = None, + location: Annotated[ + str, + typer.Option('--location', '-l', help='Datacenter location'), + ] = Locations.FIN_03, + startup_script_id: Annotated[ + str | None, + typer.Option('--startup-script', help='Startup script ID'), + ] = None, + existing_volumes: Annotated[ + list[str] | None, + typer.Option('--volume', '-v', help='Existing volume IDs to attach'), + ] = None, + spot: Annotated[ + bool, + typer.Option('--spot', help='Create as spot instance'), + ] = False, + contract: Annotated[ + str | None, + typer.Option('--contract', help='Contract type: LONG_TERM, PAY_AS_YOU_GO, SPOT'), + ] = None, + no_wait: Annotated[ + bool, + typer.Option('--no-wait', help="Don't wait for instance to provision"), + ] = False, +) -> None: + """Create a new instance.""" + client = get_client() + + max_wait_time = 0 if no_wait else 180 + + with spinner('Creating instance...'): + instance = client.instances.create( + instance_type=instance_type, + image=image, + hostname=hostname, + description=description, + ssh_key_ids=ssh_key_ids or [], + location=location, + startup_script_id=startup_script_id, + existing_volumes=existing_volumes, + is_spot=spot, + contract=contract, + max_wait_time=max_wait_time, + ) + + if cli_main.state['json_output']: + output_json(instance) + else: + success(f"Instance '{instance.hostname}' created with ID: {instance.id}") + output_single( + instance, + [ + ('ID', 'id', None), + ('Status', 'status', status_color), + ('IP', 'ip', None), + ], + ) + + +@app.command('start') +@handle_api_errors +def start_instance( + instance_id: Annotated[str, typer.Argument(help='Instance ID')], +) -> None: + """Start an instance.""" + client = get_client() + with spinner('Starting instance...'): + client.instances.action(instance_id, Actions.START) + success(f'Instance {instance_id} start initiated') + + +@app.command('stop') +@handle_api_errors +def stop_instance( + instance_id: Annotated[str, typer.Argument(help='Instance ID')], +) -> None: + """Stop (shutdown) an instance.""" + client = get_client() + with spinner('Stopping instance...'): + client.instances.action(instance_id, Actions.SHUTDOWN) + success(f'Instance {instance_id} shutdown initiated') + + +@app.command('delete') +@handle_api_errors +def delete_instance( + instance_id: Annotated[str, typer.Argument(help='Instance ID')], + force: Annotated[ + bool, + typer.Option('--force', '-f', help='Skip confirmation'), + ] = False, +) -> None: + """Delete an instance.""" + if not force: + confirm = typer.confirm(f'Are you sure you want to delete instance {instance_id}?') + if not confirm: + raise typer.Abort() + + client = get_client() + with spinner('Deleting instance...'): + client.instances.action(instance_id, Actions.DELETE) + success(f'Instance {instance_id} deletion initiated') + + +@app.command('hibernate') +@handle_api_errors +def hibernate_instance( + instance_id: Annotated[str, typer.Argument(help='Instance ID')], +) -> None: + """Hibernate an instance.""" + client = get_client() + with spinner('Hibernating instance...'): + client.instances.action(instance_id, Actions.HIBERNATE) + success(f'Instance {instance_id} hibernation initiated') + + +@app.command('restore') +@handle_api_errors +def restore_instance( + instance_id: Annotated[str, typer.Argument(help='Instance ID')], +) -> None: + """Restore a hibernated instance.""" + client = get_client() + with spinner('Restoring instance...'): + client.instances.action(instance_id, Actions.RESTORE) + success(f'Instance {instance_id} restore initiated') + + +@app.command('availability') +@handle_api_errors +def check_availability( + instance_type: Annotated[ + str | None, + typer.Argument(help='Instance type to check (omit to list all)'), + ] = None, + location: Annotated[ + str | None, + typer.Option('--location', '-l', help='Location code'), + ] = None, + spot: Annotated[ + bool, + typer.Option('--spot', help='Check spot availability'), + ] = False, +) -> None: + """Check instance type availability.""" + client = get_client() + + if instance_type: + with spinner('Checking availability...'): + available = client.instances.is_available( + instance_type, is_spot=spot, location_code=location + ) + if cli_main.state['json_output']: + output_json({'instance_type': instance_type, 'available': available}) + else: + status = '[green]Available[/green]' if available else '[red]Not Available[/red]' + typer.echo(f'{instance_type}: {status}') + else: + with spinner('Fetching availabilities...'): + availabilities = client.instances.get_availabilities( + is_spot=spot, location_code=location + ) + if cli_main.state['json_output']: + output_json(availabilities) + else: + # Flatten the data: API returns [{location_code, availabilities: [types]}] + flattened = [] + for loc_data in availabilities: + loc_code = loc_data.get('location_code', '-') + for inst_type in loc_data.get('availabilities', []): + flattened.append({'location': loc_code, 'instance_type': inst_type}) + + columns = [ + ('Location', 'location', None), + ('Instance Type', 'instance_type', None), + ] + output_table(flattened, columns, title='Available Instance Types') diff --git a/verda/cli/commands/locations.py b/verda/cli/commands/locations.py new file mode 100644 index 0000000..969f254 --- /dev/null +++ b/verda/cli/commands/locations.py @@ -0,0 +1,29 @@ +"""Commands for listing datacenter locations.""" + +import typer + +from verda.cli import main as cli_main +from verda.cli.utils.client import get_client +from verda.cli.utils.errors import handle_api_errors +from verda.cli.utils.output import output_json, output_table, spinner + +app = typer.Typer(no_args_is_help=True) + + +@app.command('list') +@handle_api_errors +def list_locations() -> None: + """List available datacenter locations.""" + client = get_client() + with spinner('Fetching locations...'): + locations = client.locations.get() + + if cli_main.state['json_output']: + output_json(locations) + else: + columns = [ + ('Code', 'code', None), + ('Name', 'name', None), + ('Country', 'country', None), + ] + output_table(locations, columns, title='Datacenter Locations') diff --git a/verda/cli/commands/ssh_keys.py b/verda/cli/commands/ssh_keys.py new file mode 100644 index 0000000..b2b3d73 --- /dev/null +++ b/verda/cli/commands/ssh_keys.py @@ -0,0 +1,112 @@ +"""Commands for managing SSH keys.""" + +from pathlib import Path +from typing import Annotated + +import typer + +from verda.cli import main as cli_main +from verda.cli.utils.client import get_client +from verda.cli.utils.errors import handle_api_errors +from verda.cli.utils.output import output_json, output_single, output_table, spinner, success + +app = typer.Typer(no_args_is_help=True) + +SSH_KEY_COLUMNS = [ + ('ID', 'id', None), + ('Name', 'name', None), + ('Public Key', 'public_key', lambda x: x[:50] + '...' if len(x) > 50 else x), +] + + +@app.command('list') +@handle_api_errors +def list_ssh_keys() -> None: + """List all SSH keys.""" + client = get_client() + with spinner('Fetching SSH keys...'): + keys = client.ssh_keys.get() + + if cli_main.state['json_output']: + output_json(keys) + else: + output_table(keys, SSH_KEY_COLUMNS, title='SSH Keys') + + +@app.command('get') +@handle_api_errors +def get_ssh_key( + key_id: Annotated[str, typer.Argument(help='SSH key ID')], +) -> None: + """Get SSH key details by ID.""" + client = get_client() + with spinner('Fetching SSH key...'): + key = client.ssh_keys.get_by_id(key_id) + + if cli_main.state['json_output']: + output_json(key) + else: + fields = [ + ('ID', 'id', None), + ('Name', 'name', None), + ('Public Key', 'public_key', None), + ] + output_single(key, fields, title=f'SSH Key: {key.name}') + + +@app.command('create') +@handle_api_errors +def create_ssh_key( + name: Annotated[ + str, + typer.Option('--name', '-n', help='Key name'), + ], + key: Annotated[ + str | None, + typer.Option('--key', '-k', help='Public key string'), + ] = None, + key_file: Annotated[ + Path | None, + typer.Option('--file', '-f', help='Path to public key file'), + ] = None, +) -> None: + """Create a new SSH key.""" + if not key and not key_file: + typer.echo('Error: Either --key or --file must be provided', err=True) + raise typer.Exit(code=1) + + if key_file: + key = key_file.read_text().strip() + + client = get_client() + with spinner('Creating SSH key...'): + ssh_key = client.ssh_keys.create(name, key) + + if cli_main.state['json_output']: + output_json(ssh_key) + else: + success(f"SSH key '{ssh_key.name}' created with ID: {ssh_key.id}") + + +@app.command('delete') +@handle_api_errors +def delete_ssh_key( + key_ids: Annotated[list[str], typer.Argument(help='SSH key ID(s) to delete')], + force: Annotated[ + bool, + typer.Option('--force', '-f', help='Skip confirmation'), + ] = False, +) -> None: + """Delete SSH key(s).""" + if not force: + confirm = typer.confirm(f'Are you sure you want to delete {len(key_ids)} SSH key(s)?') + if not confirm: + raise typer.Abort() + + client = get_client() + with spinner('Deleting SSH key(s)...'): + if len(key_ids) == 1: + client.ssh_keys.delete_by_id(key_ids[0]) + else: + client.ssh_keys.delete(key_ids) + success('SSH key(s) deleted') diff --git a/verda/cli/commands/startup_scripts.py b/verda/cli/commands/startup_scripts.py new file mode 100644 index 0000000..b4b2ed0 --- /dev/null +++ b/verda/cli/commands/startup_scripts.py @@ -0,0 +1,112 @@ +"""Commands for managing startup scripts.""" + +from pathlib import Path +from typing import Annotated + +import typer + +from verda.cli import main as cli_main +from verda.cli.utils.client import get_client +from verda.cli.utils.errors import handle_api_errors +from verda.cli.utils.output import output_json, output_single, output_table, spinner, success + +app = typer.Typer(no_args_is_help=True) + +SCRIPT_COLUMNS = [ + ('ID', 'id', None), + ('Name', 'name', None), + ('Script', 'script', lambda x: x[:40] + '...' if len(x) > 40 else x), +] + + +@app.command('list') +@handle_api_errors +def list_scripts() -> None: + """List all startup scripts.""" + client = get_client() + with spinner('Fetching startup scripts...'): + scripts = client.startup_scripts.get() + + if cli_main.state['json_output']: + output_json(scripts) + else: + output_table(scripts, SCRIPT_COLUMNS, title='Startup Scripts') + + +@app.command('get') +@handle_api_errors +def get_script( + script_id: Annotated[str, typer.Argument(help='Script ID')], +) -> None: + """Get startup script details by ID.""" + client = get_client() + with spinner('Fetching startup script...'): + script = client.startup_scripts.get_by_id(script_id) + + if cli_main.state['json_output']: + output_json(script) + else: + fields = [ + ('ID', 'id', None), + ('Name', 'name', None), + ('Script', 'script', None), + ] + output_single(script, fields, title=f'Startup Script: {script.name}') + + +@app.command('create') +@handle_api_errors +def create_script( + name: Annotated[ + str, + typer.Option('--name', '-n', help='Script name'), + ], + script: Annotated[ + str | None, + typer.Option('--script', '-s', help='Script content'), + ] = None, + script_file: Annotated[ + Path | None, + typer.Option('--file', '-f', help='Path to script file'), + ] = None, +) -> None: + """Create a new startup script.""" + if not script and not script_file: + typer.echo('Error: Either --script or --file must be provided', err=True) + raise typer.Exit(code=1) + + if script_file: + script = script_file.read_text() + + client = get_client() + with spinner('Creating startup script...'): + result = client.startup_scripts.create(name, script) + + if cli_main.state['json_output']: + output_json(result) + else: + success(f"Startup script '{result.name}' created with ID: {result.id}") + + +@app.command('delete') +@handle_api_errors +def delete_script( + script_ids: Annotated[list[str], typer.Argument(help='Script ID(s) to delete')], + force: Annotated[ + bool, + typer.Option('--force', '-f', help='Skip confirmation'), + ] = False, +) -> None: + """Delete startup script(s).""" + if not force: + confirm = typer.confirm(f'Delete {len(script_ids)} script(s)?') + if not confirm: + raise typer.Abort() + + client = get_client() + with spinner('Deleting startup script(s)...'): + if len(script_ids) == 1: + client.startup_scripts.delete_by_id(script_ids[0]) + else: + client.startup_scripts.delete(script_ids) + success('Script(s) deleted') diff --git a/verda/cli/commands/volume_types.py b/verda/cli/commands/volume_types.py new file mode 100644 index 0000000..c13f0a2 --- /dev/null +++ b/verda/cli/commands/volume_types.py @@ -0,0 +1,28 @@ +"""Commands for listing available volume types.""" + +import typer + +from verda.cli import main as cli_main +from verda.cli.utils.client import get_client +from verda.cli.utils.errors import handle_api_errors +from verda.cli.utils.output import output_json, output_table, spinner + +app = typer.Typer(no_args_is_help=True) + + +@app.command('list') +@handle_api_errors +def list_volume_types() -> None: + """List available volume types with pricing.""" + client = get_client() + with spinner('Fetching volume types...'): + types = client.volume_types.get() + + if cli_main.state['json_output']: + output_json(types) + else: + columns = [ + ('Type', 'type', None), + ('Price/GB/Month', 'price_per_month_per_gb', lambda x: f'${x:.4f}'), + ] + output_table(types, columns, title='Volume Types') diff --git a/verda/cli/commands/volumes.py b/verda/cli/commands/volumes.py new file mode 100644 index 0000000..0845e81 --- /dev/null +++ b/verda/cli/commands/volumes.py @@ -0,0 +1,248 @@ +"""Commands for managing storage volumes.""" + +from typing import Annotated + +import typer + +from verda.cli import main as cli_main +from verda.cli.utils.client import get_client +from verda.cli.utils.errors import handle_api_errors +from verda.cli.utils.output import output_json, output_single, output_table, spinner, success +from verda.constants import Locations, VolumeTypes + +app = typer.Typer(no_args_is_help=True) + + +def status_color(status: str) -> str: + """Format status with color.""" + colors = { + 'attached': '[green]attached[/green]', + 'detached': '[yellow]detached[/yellow]', + 'creating': '[blue]creating[/blue]', + 'ordered': '[yellow]ordered[/yellow]', + 'cloning': '[blue]cloning[/blue]', + 'deleting': '[red]deleting[/red]', + 'deleted': '[red]deleted[/red]', + } + return colors.get(status, status) + + +VOLUME_COLUMNS = [ + ('ID', 'id', None), + ('Name', 'name', None), + ('Size (GB)', 'size', None), + ('Type', 'type', None), + ('Status', 'status', status_color), + ('Location', 'location', None), + ('Instance ID', 'instance_id', lambda x: x or '-'), +] + + +@app.command('list') +@handle_api_errors +def list_volumes( + status: Annotated[ + str | None, + typer.Option('--status', '-s', help='Filter by status'), + ] = None, +) -> None: + """List all volumes.""" + client = get_client() + with spinner('Fetching volumes...'): + volumes = client.volumes.get(status=status) + + if cli_main.state['json_output']: + output_json(volumes) + else: + output_table(volumes, VOLUME_COLUMNS, title='Volumes') + + +@app.command('get') +@handle_api_errors +def get_volume( + volume_id: Annotated[str, typer.Argument(help='Volume ID')], +) -> None: + """Get volume details by ID.""" + client = get_client() + with spinner('Fetching volume...'): + volume = client.volumes.get_by_id(volume_id) + + if cli_main.state['json_output']: + output_json(volume) + else: + fields = [ + ('ID', 'id', None), + ('Name', 'name', None), + ('Size (GB)', 'size', None), + ('Type', 'type', None), + ('Status', 'status', status_color), + ('Location', 'location', None), + ('Instance ID', 'instance_id', lambda x: x or 'Not attached'), + ('OS Volume', 'is_os_volume', None), + ('Created', 'created_at', None), + ] + output_single(volume, fields, title=f'Volume: {volume.name}') + + +@app.command('create') +@handle_api_errors +def create_volume( + name: Annotated[ + str, + typer.Option('--name', '-n', help='Volume name'), + ], + size: Annotated[ + int, + typer.Option('--size', '-s', help='Size in GB'), + ], + volume_type: Annotated[ + str, + typer.Option('--type', '-t', help='Volume type: NVMe, HDD, NVMe_Shared'), + ] = VolumeTypes.NVMe, + location: Annotated[ + str, + typer.Option('--location', '-l', help='Datacenter location'), + ] = Locations.FIN_03, + instance_id: Annotated[ + str | None, + typer.Option('--instance', '-i', help='Attach to instance'), + ] = None, +) -> None: + """Create a new volume.""" + client = get_client() + with spinner('Creating volume...'): + volume = client.volumes.create( + type=volume_type, + name=name, + size=size, + instance_id=instance_id, + location=location, + ) + + if cli_main.state['json_output']: + output_json(volume) + else: + success(f"Volume '{volume.name}' created with ID: {volume.id}") + + +@app.command('attach') +@handle_api_errors +def attach_volume( + volume_ids: Annotated[list[str], typer.Argument(help='Volume ID(s) to attach')], + instance_id: Annotated[ + str, + typer.Option('--instance', '-i', help='Instance ID to attach to'), + ], +) -> None: + """Attach volume(s) to an instance.""" + client = get_client() + with spinner('Attaching volume(s)...'): + client.volumes.attach(volume_ids, instance_id) + success(f'Volume(s) attached to instance {instance_id}') + + +@app.command('detach') +@handle_api_errors +def detach_volume( + volume_ids: Annotated[list[str], typer.Argument(help='Volume ID(s) to detach')], +) -> None: + """Detach volume(s) from instance(s).""" + client = get_client() + with spinner('Detaching volume(s)...'): + client.volumes.detach(volume_ids) + success('Volume(s) detached') + + +@app.command('delete') +@handle_api_errors +def delete_volume( + volume_ids: Annotated[list[str], typer.Argument(help='Volume ID(s) to delete')], + permanent: Annotated[ + bool, + typer.Option('--permanent', '-p', help='Permanently delete (skip trash)'), + ] = False, + force: Annotated[ + bool, + typer.Option('--force', '-f', help='Skip confirmation'), + ] = False, +) -> None: + """Delete volume(s).""" + if not force: + confirm = typer.confirm(f'Are you sure you want to delete {len(volume_ids)} volume(s)?') + if not confirm: + raise typer.Abort() + + client = get_client() + with spinner('Deleting volume(s)...'): + client.volumes.delete(volume_ids, is_permanent=permanent) + success('Volume(s) deleted') + + +@app.command('rename') +@handle_api_errors +def rename_volume( + volume_ids: Annotated[list[str], typer.Argument(help='Volume ID(s) to rename')], + name: Annotated[ + str, + typer.Option('--name', '-n', help='New name'), + ], +) -> None: + """Rename volume(s).""" + client = get_client() + with spinner('Renaming volume(s)...'): + client.volumes.rename(volume_ids, name) + success(f"Volume(s) renamed to '{name}'") + + +@app.command('resize') +@handle_api_errors +def resize_volume( + volume_ids: Annotated[list[str], typer.Argument(help='Volume ID(s) to resize')], + size: Annotated[ + int, + typer.Option('--size', '-s', help='New size in GB'), + ], +) -> None: + """Increase volume size (can only increase, not decrease).""" + client = get_client() + with spinner('Resizing volume(s)...'): + client.volumes.increase_size(volume_ids, size) + success(f'Volume(s) resized to {size}GB') + + +@app.command('clone') +@handle_api_errors +def clone_volume( + volume_id: Annotated[str, typer.Argument(help='Volume ID to clone')], + name: Annotated[ + str | None, + typer.Option('--name', '-n', help='Name for cloned volume'), + ] = None, + volume_type: Annotated[ + str | None, + typer.Option('--type', '-t', help='Type for cloned volume'), + ] = None, +) -> None: + """Clone a volume.""" + client = get_client() + with spinner('Cloning volume...'): + volume = client.volumes.clone(volume_id, name=name, type=volume_type) + + if cli_main.state['json_output']: + output_json(volume) + else: + success(f'Volume cloned with ID: {volume.id}') + + +@app.command('trash') +@handle_api_errors +def list_trash() -> None: + """List volumes in trash.""" + client = get_client() + with spinner('Fetching trash...'): + volumes = client.volumes.get_in_trash() + + if cli_main.state['json_output']: + output_json(volumes) + else: + output_table(volumes, VOLUME_COLUMNS, title='Volumes in Trash') diff --git a/verda/cli/main.py b/verda/cli/main.py new file mode 100644 index 0000000..fe365b0 --- /dev/null +++ b/verda/cli/main.py @@ -0,0 +1,76 @@ +"""Main Typer application and entry point for verda-cli.""" + +from typing import Annotated + +import typer + +from verda._version import __version__ +from verda.cli.commands import ( + balance, + clusters, + images, + instance_types, + instances, + locations, + ssh_keys, + startup_scripts, + volume_types, + volumes, +) + +app = typer.Typer( + name='verda-cli', + help='Verda Cloud CLI - Manage cloud instances, volumes, and clusters', + no_args_is_help=True, +) + +# Global state for JSON output mode +state = {'json_output': False} + + +def version_callback(value: bool) -> None: + """Show version and exit.""" + if value: + typer.echo(f'verda-cli version {__version__}') + raise typer.Exit() + + +@app.callback() +def main( + json_output: Annotated[ + bool, + typer.Option('--json', '-j', help='Output in JSON format'), + ] = False, + _version: Annotated[ + bool, + typer.Option( + '--version', + '-v', + help='Show version and exit', + callback=version_callback, + is_eager=True, + ), + ] = False, +) -> None: + """Verda Cloud CLI for managing cloud resources. + + Set VERDA_CLIENT_ID and VERDA_CLIENT_SECRET environment variables to authenticate. + """ + state['json_output'] = json_output + + +# Register sub-applications +app.add_typer(instances.app, name='instances', help='Manage compute instances') +app.add_typer(volumes.app, name='volumes', help='Manage storage volumes') +app.add_typer(clusters.app, name='clusters', help='Manage compute clusters') +app.add_typer(ssh_keys.app, name='ssh-keys', help='Manage SSH keys') +app.add_typer(startup_scripts.app, name='startup-scripts', help='Manage startup scripts') +app.add_typer(images.app, name='images', help='List available OS images') +app.add_typer(instance_types.app, name='instance-types', help='List available instance types') +app.add_typer(volume_types.app, name='volume-types', help='List available volume types') +app.add_typer(locations.app, name='locations', help='List datacenter locations') +app.add_typer(balance.app, name='balance', help='Check account balance') + + +if __name__ == '__main__': + app() diff --git a/verda/cli/utils/__init__.py b/verda/cli/utils/__init__.py new file mode 100644 index 0000000..50efadf --- /dev/null +++ b/verda/cli/utils/__init__.py @@ -0,0 +1 @@ +"""CLI utility modules.""" diff --git a/verda/cli/utils/client.py b/verda/cli/utils/client.py new file mode 100644 index 0000000..669706c --- /dev/null +++ b/verda/cli/utils/client.py @@ -0,0 +1,51 @@ +"""Client initialization from environment variables.""" + +import os + +import typer + +from verda import VerdaClient + + +def get_client() -> VerdaClient: + """Initialize VerdaClient from environment variables. + + Required environment variables: + VERDA_CLIENT_ID: API client ID + VERDA_CLIENT_SECRET: API client secret + + Optional environment variables: + VERDA_BASE_URL: API base URL (default: https://api.datacrunch.io/v1) + VERDA_INFERENCE_KEY: Inference API key + + Returns: + Initialized VerdaClient instance + + Raises: + typer.Exit: If required credentials are missing + """ + client_id = os.environ.get('VERDA_CLIENT_ID') + client_secret = os.environ.get('VERDA_CLIENT_SECRET') + base_url = os.environ.get('VERDA_BASE_URL') + inference_key = os.environ.get('VERDA_INFERENCE_KEY') + + if not client_id or not client_secret: + typer.echo( + 'Error: VERDA_CLIENT_ID and VERDA_CLIENT_SECRET environment variables are required.', + err=True, + ) + typer.echo('Set them with:', err=True) + typer.echo(' export VERDA_CLIENT_ID=your_client_id', err=True) + typer.echo(' export VERDA_CLIENT_SECRET=your_client_secret', err=True) + raise typer.Exit(code=1) + + kwargs = { + 'client_id': client_id, + 'client_secret': client_secret, + } + if base_url: + kwargs['base_url'] = base_url + if inference_key: + kwargs['inference_key'] = inference_key + + return VerdaClient(**kwargs) diff --git a/verda/cli/utils/errors.py b/verda/cli/utils/errors.py new file mode 100644 index 0000000..f0d85a5 --- /dev/null +++ b/verda/cli/utils/errors.py @@ -0,0 +1,40 @@ +"""Error handling utilities for CLI commands.""" + +import functools +from typing import TypeVar + +import typer + +from verda.cli.utils.output import error +from verda.exceptions import APIException + +F = TypeVar('F') + + +def handle_api_errors(func: F) -> F: + """Decorator to handle API errors gracefully. + + Catches common exceptions and displays user-friendly error messages. + + Args: + func: Function to wrap + + Returns: + Wrapped function with error handling + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except APIException as e: + error(f'API Error [{e.code}]: {e.message}') + raise typer.Exit(code=1) from None + except TimeoutError as e: + error(f'Timeout: {e}') + raise typer.Exit(code=1) from None + except ConnectionError as e: + error(f'Connection error: {e}') + raise typer.Exit(code=1) from None + + return wrapper # type: ignore[return-value] diff --git a/verda/cli/utils/output.py b/verda/cli/utils/output.py new file mode 100644 index 0000000..202be85 --- /dev/null +++ b/verda/cli/utils/output.py @@ -0,0 +1,153 @@ +"""Output formatting utilities for table and JSON output.""" + +import json +from collections.abc import Callable, Generator +from contextlib import contextmanager +from dataclasses import asdict, is_dataclass +from typing import Any + +from rich.console import Console +from rich.table import Table + +console = Console() + + +@contextmanager +def spinner(message: str = 'Loading...') -> Generator[None, None, None]: + """Context manager that shows a loading spinner. + + Args: + message: Message to display while loading + + Yields: + None + """ + with console.status(f'[cyan]{message}[/cyan]', spinner='dots'): + yield + + +def to_dict(obj: Any) -> dict | list | Any: + """Convert an object to a dictionary for JSON serialization. + + Args: + obj: Object to convert + + Returns: + Dictionary representation of the object + """ + if obj is None: + return None + if isinstance(obj, list): + return [to_dict(item) for item in obj] + if isinstance(obj, dict): + return obj + if is_dataclass(obj) and hasattr(obj, 'to_dict'): + return obj.to_dict() + if is_dataclass(obj): + return asdict(obj) + if hasattr(obj, '__dict__'): + return {k: v for k, v in obj.__dict__.items() if not k.startswith('_')} + return obj + + +def output_json(data: Any) -> None: + """Output data as formatted JSON. + + Args: + data: Data to output (will be converted to dict if needed) + """ + json_data = to_dict(data) + console.print_json(json.dumps(json_data, indent=2, default=str)) + + +def output_table( + data: list[Any], + columns: list[tuple[str, str, Callable[[Any], str] | None]], + title: str | None = None, +) -> None: + """Output data as a rich table. + + Args: + data: List of objects to display + columns: List of (header, attr_name, formatter) tuples + title: Optional table title + """ + table = Table(title=title, show_header=True, header_style='bold cyan') + + for header, _, _ in columns: + table.add_column(header) + + for item in data: + row = [] + for _, attr, formatter in columns: + if hasattr(item, attr): + value = getattr(item, attr) + elif isinstance(item, dict): + value = item.get(attr) + else: + value = None + + if formatter and value is not None: + value = formatter(value) + row.append(str(value) if value is not None else '-') + table.add_row(*row) + + console.print(table) + + +def output_single( + data: Any, + fields: list[tuple[str, str, Callable[[Any], str] | None]], + title: str | None = None, +) -> None: + """Output a single object's details as a key-value table. + + Args: + data: Object to display + fields: List of (label, attr_name, formatter) tuples + title: Optional table title + """ + table = Table(title=title, show_header=False, box=None) + table.add_column('Field', style='bold') + table.add_column('Value') + + for label, attr, formatter in fields: + if hasattr(data, attr): + value = getattr(data, attr) + elif isinstance(data, dict): + value = data.get(attr) + else: + value = None + + if formatter and value is not None: + value = formatter(value) + table.add_row(label, str(value) if value is not None else '-') + + console.print(table) + + +def success(message: str) -> None: + """Print a success message. + + Args: + message: Message to display + """ + console.print(f'[green]Success:[/green] {message}') + + +def error(message: str) -> None: + """Print an error message. + + Args: + message: Message to display + """ + console.print(f'[red]Error:[/red] {message}', style='red') + + +def warning(message: str) -> None: + """Print a warning message. + + Args: + message: Message to display + """ + console.print(f'[yellow]Warning:[/yellow] {message}')