diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py index 116377e49..2234376d5 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py @@ -4,7 +4,6 @@ from jumpstarter_cli_common.alias import AliasedGroup from jumpstarter_cli_common.blocking import blocking from jumpstarter_cli_common.opt import ( - OutputMode, OutputType, confirm_insecure_tls, opt_context, @@ -15,7 +14,8 @@ opt_nointeractive, opt_output_all, ) -from jumpstarter_kubernetes import ClientsV1Alpha1Api, ExportersV1Alpha1Api, V1Alpha1Client, V1Alpha1Exporter +from jumpstarter_cli_common.print import model_print +from jumpstarter_kubernetes import ClientsV1Alpha1Api, ExportersV1Alpha1Api from kubernetes_asyncio.client.exceptions import ApiException from kubernetes_asyncio.config.config_exception import ConfigException @@ -35,15 +35,6 @@ def create(): """Create Jumpstarter Kubernetes objects""" -def print_created_client(client: V1Alpha1Client, output: OutputType): - if output == OutputMode.JSON: - click.echo(client.dump_json()) - elif output == OutputMode.YAML: - click.echo(client.dump_yaml()) - elif output == OutputMode.NAME: - click.echo(f"client.jumpstarter.dev/{client.metadata.name}") - - @create.command("client") @click.argument("name", type=str, required=False, default=None) @click.option( @@ -116,28 +107,19 @@ async def create_client( client_config.tls.insecure = insecure_tls_config ClientConfigV1Alpha1.save(client_config, out) # If this is the only client config, set it as default - if out is None and len(ClientConfigV1Alpha1.list()) == 1: + if out is None and len(ClientConfigV1Alpha1.list().items) == 1: user_config = UserConfigV1Alpha1.load_or_create() user_config.config.current_client = client_config UserConfigV1Alpha1.save(user_config) if output is None: click.echo(f"Client configuration successfully saved to {client_config.path}") - print_created_client(created_client, output) + model_print(created_client, output, namespace=namespace) except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: handle_k8s_config_exception(e) -def print_created_exporter(exporter: V1Alpha1Exporter, output: OutputType): - if output == OutputMode.JSON: - click.echo(exporter.dump_json()) - elif output == OutputMode.YAML: - click.echo(exporter.dump_yaml()) - elif output == OutputMode.NAME: - click.echo(f"exporter.jumpstarter.dev/{exporter.metadata.name}") - - @create.command("exporter") @click.argument("name", type=str, required=False, default=None) @click.option( @@ -191,7 +173,7 @@ async def create_exporter( ExporterConfigV1Alpha1.save(exporter_config, out) if output is None: click.echo(f"Exporter configuration successfully saved to {exporter_config.path}") - print_created_exporter(created_exporter, output) + model_print(created_exporter, output, namespace=namespace) except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py index 21b2d67ca..506b0c3e0 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py @@ -10,6 +10,7 @@ opt_namespace, opt_output_all, ) +from jumpstarter_cli_common.print import model_print from jumpstarter_kubernetes import ( ClientsV1Alpha1Api, ExportersV1Alpha1Api, @@ -22,7 +23,6 @@ handle_k8s_api_exception, handle_k8s_config_exception, ) -from .print import print_client, print_clients, print_exporter, print_exporters, print_lease, print_leases @click.group(cls=AliasedGroup) @@ -45,10 +45,10 @@ async def get_client( async with ClientsV1Alpha1Api(namespace, kubeconfig, context) as api: if name is not None: client = await api.get_client(name) - print_client(client, output) + model_print(client, output, namespace=namespace) else: clients = await api.list_clients() - print_clients(clients, namespace, output) + model_print(clients, output, namespace=namespace) except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: @@ -76,10 +76,10 @@ async def get_exporter( async with ExportersV1Alpha1Api(namespace, kubeconfig, context) as api: if name is not None: exporter = await api.get_exporter(name) - print_exporter(exporter, devices, output) + model_print(exporter, output, devices=devices, namespace=namespace) else: exporters = await api.list_exporters() - print_exporters(exporters, namespace, devices, output) + model_print(exporters, output, devices=devices, namespace=namespace) except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: @@ -101,10 +101,10 @@ async def get_lease( async with LeasesV1Alpha1Api(namespace, kubeconfig, context) as api: if name is not None: lease = await api.get_lease(name) - print_lease(lease, output) + model_print(lease, output, namespace=namespace) else: leases = await api.list_leases() - print_leases(leases, namespace, output) + model_print(leases, output, namespace=namespace) except ApiException as e: handle_k8s_api_exception(e) except ConfigException as e: diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py index 119b75082..ad627df33 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py @@ -267,7 +267,7 @@ def test_get_clients(_load_kube_config_mock, list_clients_mock: AsyncMock): # No clients found list_clients_mock.return_value = V1Alpha1ClientList(items=[]) result = runner.invoke(get, ["clients"]) - assert result.exit_code == 1 + assert result.exit_code == 0 assert "No resources found" in result.output list_clients_mock.reset_mock() @@ -636,7 +636,7 @@ def test_get_exporters(_load_kube_config_mock, list_exporters_mock: AsyncMock): return_value=V1Alpha1ExporterList(items=[]), ): result = runner.invoke(get, ["exporters"]) - assert result.exit_code == 1 + assert result.exit_code == 0 assert "No resources found" in result.output @@ -800,7 +800,7 @@ def test_get_exporters_devices(_load_kube_config_mock, list_exporters_mock: Asyn # No exporters found list_exporters_mock.return_value = V1Alpha1ExporterList(items=[]) result = runner.invoke(get, ["exporters", "--devices"]) - assert result.exit_code == 1 + assert result.exit_code == 0 assert "No resources found" in result.output @@ -1193,5 +1193,5 @@ def test_get_leases(_load_kube_config_mock, list_leases_mock: AsyncMock): # No leases found list_leases_mock.return_value = V1Alpha1LeaseList(items=[]) result = runner.invoke(get, ["leases"]) - assert result.exit_code == 1 + assert result.exit_code == 0 assert "No resources found" in result.output diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py index a002737ac..e4bb656f1 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py @@ -84,7 +84,7 @@ async def import_client( client_config.tls.insecure = insecure_tls_config config_path = ClientConfigV1Alpha1.save(client_config, out) # If this is the only client config, set it as default - if out is None and len(ClientConfigV1Alpha1.list()) == 1: + if out is None and len(ClientConfigV1Alpha1.list().items) == 1: user_config = UserConfigV1Alpha1.load_or_create() user_config.config.current_client = client_config UserConfigV1Alpha1.save(user_config) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py deleted file mode 100644 index 679de1e0d..000000000 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py +++ /dev/null @@ -1,175 +0,0 @@ -import click -from jumpstarter_cli_common.opt import OutputMode, OutputType -from jumpstarter_cli_common.table import make_table -from jumpstarter_cli_common.time import time_since -from jumpstarter_kubernetes import ( - V1Alpha1Client, - V1Alpha1Exporter, - V1Alpha1Lease, - V1Alpha1List, -) - -CLIENT_COLUMNS = ["NAME", "ENDPOINT", "AGE"] - - -def make_client_row(client: V1Alpha1Client): - return { - "NAME": client.metadata.name, - "ENDPOINT": client.status.endpoint if client.status is not None else "", - "AGE": time_since(client.metadata.creation_timestamp), - } - - -def print_client(client: V1Alpha1Client, output: OutputType): - if output == OutputMode.JSON: - click.echo(client.dump_json()) - elif output == OutputMode.YAML: - click.echo(client.dump_yaml()) - elif output == OutputMode.NAME: - click.echo(f"client.jumpstarter.dev/{client.metadata.name}") - else: - click.echo(make_table(CLIENT_COLUMNS, [make_client_row(client)])) - - -def print_clients(clients: V1Alpha1List[V1Alpha1Client], namespace: str, output: OutputType): - if output == OutputMode.JSON: - click.echo(clients.dump_json()) - elif output == OutputMode.YAML: - click.echo(clients.dump_yaml()) - elif output == OutputMode.NAME: - for item in clients.items: - click.echo(f"client.jumpstarter.dev/{item.metadata.name}") - elif len(clients.items) == 0: - raise click.ClickException(f'No resources found in "{namespace}" namespace') - else: - click.echo(make_table(CLIENT_COLUMNS, list(map(make_client_row, clients.items)))) - - -EXPORTER_COLUMNS = ["NAME", "ENDPOINT", "DEVICES", "AGE"] -DEVICE_COLUMNS = ["NAME", "ENDPOINT", "AGE", "LABELS", "UUID"] - - -def make_exporter_row(exporter: V1Alpha1Exporter): - """Make an exporter row to print""" - return { - "NAME": exporter.metadata.name, - "ENDPOINT": exporter.status.endpoint, - "DEVICES": str(len(exporter.status.devices) if exporter.status and exporter.status.devices else 0), - "AGE": time_since(exporter.metadata.creation_timestamp), - } - - -def get_device_rows(exporters: list[V1Alpha1Exporter]): - """Get the device rows to print from the exporters""" - devices = [] - for e in exporters: - if e.status is not None: - for d in e.status.devices: - labels = [] - if d.labels is not None: - for label in d.labels: - labels.append(f"{label}:{str(d.labels[label])}") - devices.append( - { - "NAME": e.metadata.name, - "ENDPOINT": e.status.endpoint, - "AGE": time_since(e.metadata.creation_timestamp), - "LABELS": ",".join(labels), - "UUID": d.uuid, - } - ) - return devices - - -def print_exporter(exporter: V1Alpha1Exporter, devices: bool, output: OutputType): - if output == OutputMode.JSON: - click.echo(exporter.dump_json()) - elif output == OutputMode.YAML: - click.echo(exporter.dump_yaml()) - elif output == OutputMode.NAME: - click.echo(f"exporter.jumpstarter.dev/{exporter.metadata.name}") - elif devices: - # Print the devices for the exporter - click.echo(make_table(DEVICE_COLUMNS, get_device_rows([exporter]))) - else: - click.echo(make_table(EXPORTER_COLUMNS, [make_exporter_row(exporter)])) - - -def print_exporters(exporters: V1Alpha1List[V1Alpha1Exporter], namespace: str, devices: bool, output: OutputType): - if output == OutputMode.JSON: - click.echo(exporters.dump_json()) - elif output == OutputMode.YAML: - click.echo(exporters.dump_yaml()) - elif output == OutputMode.NAME: - for item in exporters.items: - click.echo(f"exporter.jumpstarter.dev/{item.metadata.name}") - elif len(exporters.items) == 0: - raise click.ClickException(f'No resources found in "{namespace}" namespace') - elif devices: - click.echo(make_table(DEVICE_COLUMNS, get_device_rows(exporters.items))) - else: - click.echo(make_table(EXPORTER_COLUMNS, list(map(make_exporter_row, exporters.items)))) - - -LEASE_COLUMNS = ["NAME", "CLIENT", "SELECTOR", "EXPORTER", "STATUS", "REASON", "BEGIN", "END", "DURATION", "AGE"] - - -def get_reason(lease: V1Alpha1Lease): - condition = lease.status.conditions[-1] if len(lease.status.conditions) > 0 else None - reason = condition.reason if condition is not None else "Unknown" - status = condition.status if condition is not None else "False" - if reason == "Ready": - if status == "True": - return "Ready" - else: - return "Waiting" - elif reason == "Expired": - if status == "True": - return "Expired" - else: - return "Complete" - else: - return reason - - -def make_lease_row(lease: V1Alpha1Lease): - selectors = [] - for label in lease.spec.selector.match_labels: - selectors.append(f"{label}:{str(lease.spec.selector.match_labels[label])}") - return { - "NAME": lease.metadata.name, - "CLIENT": lease.spec.client.name if lease.spec.client is not None else "", - "SELECTOR": ",".join(selectors) if len(selectors) > 0 else "*", - "EXPORTER": lease.status.exporter.name if lease.status.exporter is not None else "", - "DURATION": lease.spec.duration, - "STATUS": "Ended" if lease.status.ended else "InProgress", - "REASON": get_reason(lease), - "BEGIN": lease.status.begin_time if lease.status.begin_time is not None else "", - "END": lease.status.end_time if lease.status.end_time is not None else "", - "AGE": time_since(lease.metadata.creation_timestamp), - } - - -def print_lease(lease: V1Alpha1Lease, output: OutputType): - if output == OutputMode.JSON: - click.echo(lease.dump_json()) - elif output == OutputMode.YAML: - click.echo(lease.dump_yaml()) - elif output == OutputMode.NAME: - click.echo(f"lease.jumpstarter.dev/{lease.metadata.name}") - else: - click.echo(make_table(LEASE_COLUMNS, [make_lease_row(lease)])) - - -def print_leases(leases: V1Alpha1List[V1Alpha1Lease], namespace: str, output: OutputType): - if output == OutputMode.JSON: - click.echo(leases.dump_json()) - elif output == OutputMode.YAML: - click.echo(leases.dump_yaml()) - elif output == OutputMode.NAME: - for item in leases.items: - click.echo(f"lease.jumpstarter.dev/{item.metadata.name}") - elif len(leases.items) == 0: - raise click.ClickException(f'No resources found in "{namespace}" namespace') - else: - click.echo(make_table(LEASE_COLUMNS, list(map(make_lease_row, leases.items)))) diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/print.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/print.py new file mode 100644 index 000000000..6d8cc6cd9 --- /dev/null +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/print.py @@ -0,0 +1,74 @@ +import yaml +from pydantic import BaseModel +from rich.console import Console +from rich.table import Table + +from .opt import OutputMode, OutputType + + +def model_print( # noqa: C901 + model: BaseModel, + output: OutputType, + namespace: str | None = None, + **kwargs, +): + console = Console() + + match output: + case OutputMode.JSON: + console.print( + model.model_dump_json( + indent=4, + by_alias=True, + ) + ) + case OutputMode.YAML: + console.print( + yaml.safe_dump( + model.model_dump( + mode="json", + by_alias=True, + ), + indent=2, + ) + ) + case OutputMode.NAME: + names = [] + + try: + model.rich_add_names(names) + except AttributeError as err: + raise NotImplementedError from err + + for name in names: + console.print(name) + case OutputMode.PATH: + paths = [] + + try: + model.rich_add_paths(paths) + except AttributeError as err: + raise NotImplementedError from err + + for path in paths: + console.print(path) + case _: + table = Table( + box=None, + header_style=None, + pad_edge=None, + ) + + try: + model.rich_add_columns(table, **kwargs) + model.rich_add_rows(table, **kwargs) + except AttributeError as err: + raise NotImplementedError from err + + if len(table.rows) == 0: + if namespace: + console.print("No resources found in {} namespace.".format(namespace)) + else: + console.print("No resources found.") + else: + console.print(table) diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/table.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/table.py deleted file mode 100644 index b52178176..000000000 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/table.py +++ /dev/null @@ -1,29 +0,0 @@ -from io import StringIO -from typing import cast - -from rich.console import Console -from rich.table import Table - - -def make_table(columns: list[str], values: list[dict]): - """Print a pretty table from a list of `columns` and a list of `values`, each of which is a valid `dict`""" - - table = Table( - box=None, - header_style=None, - pad_edge=False, - ) - - for name in columns: - table.add_column( - name, - overflow="fold", - no_wrap=(name == "UUID"), - ) - - for v in values: - table.add_row(*[v[k] for k in columns]) - - console = Console(file=StringIO()) - console.print(table) - return cast(StringIO, console.file).getvalue() diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/table_test.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/table_test.py deleted file mode 100644 index 08dffdc45..000000000 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/table_test.py +++ /dev/null @@ -1,13 +0,0 @@ -from .table import make_table - -EXPECTED_TABLE = """ -TEST HELLO -123456 There -""".lstrip() - - -def test_make_table(): - COLUMNS = ["TEST", "HELLO"] - DATA = [{"TEST": "123456", "HELLO": "There"}] - table = make_table(COLUMNS, DATA) - assert table == EXPECTED_TABLE diff --git a/packages/jumpstarter-cli-driver/jumpstarter_cli_driver/driver.py b/packages/jumpstarter-cli-driver/jumpstarter_cli_driver/driver.py index 47c95a374..4c01b36f5 100644 --- a/packages/jumpstarter-cli-driver/jumpstarter_cli_driver/driver.py +++ b/packages/jumpstarter-cli-driver/jumpstarter_cli_driver/driver.py @@ -1,13 +1,29 @@ from importlib.metadata import entry_points import click -from jumpstarter_cli_common.table import make_table +from rich.console import Console +from rich.table import Table @click.command("list") -async def list_drivers(): +def list_drivers(): drivers = list(entry_points(group="jumpstarter.drivers")) if not drivers: click.echo("No drivers found.") else: - click.echo(make_table(["NAME", "TYPE"], [{"NAME": e.name, "TYPE": e.value.replace(":", ".")} for e in drivers])) + table = Table( + box=None, + header_style=None, + pad_edge=None, + ) + + table.add_column("NAME") + table.add_column("TYPE") + + for driver in drivers: + table.add_row( + driver.name, + driver.value.replace(":", "."), + ) + + Console().print(table) diff --git a/packages/jumpstarter-cli/jumpstarter_cli/config_client.py b/packages/jumpstarter-cli/jumpstarter_cli/config_client.py index 3293cb7a3..0979bab21 100644 --- a/packages/jumpstarter-cli/jumpstarter_cli/config_client.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/config_client.py @@ -10,9 +10,9 @@ opt_output_all, opt_output_path_only, ) -from jumpstarter_cli_common.table import make_table +from jumpstarter_cli_common.print import model_print -from jumpstarter.config.client import ClientConfigListV1Alpha1, ClientConfigV1Alpha1, ClientConfigV1Alpha1Drivers +from jumpstarter.config.client import ClientConfigV1Alpha1, ClientConfigV1Alpha1Drivers from jumpstarter.config.common import ObjectMeta from jumpstarter.config.user import UserConfigV1Alpha1 @@ -94,7 +94,7 @@ def create_client_config( path = ClientConfigV1Alpha1.save(config, out) # If this is the only client config, set it as default - if out is None and len(ClientConfigV1Alpha1.list()) == 1: + if out is None and len(ClientConfigV1Alpha1.list().items) == 1: user_config = UserConfigV1Alpha1.load_or_create() user_config.config.current_client = config UserConfigV1Alpha1.save(user_config) @@ -110,7 +110,7 @@ def set_next_client(name: str): and user_config.config.current_client is not None and user_config.config.current_client.alias == name ): - for c in ClientConfigV1Alpha1.list(): + for c in ClientConfigV1Alpha1.list().items: if c.alias != name: # Use the next available client config user_config.use_client(c.alias) @@ -135,34 +135,9 @@ def delete_client_config(name: str, output: PathOutputType): @opt_output_all @handle_exceptions def list_client_configs(output: OutputType): - # Allow listing if there is no user config defined - current_name = None - if UserConfigV1Alpha1.exists(): - current_client = UserConfigV1Alpha1.load().config.current_client - current_name = current_client.alias if current_client is not None else None - configs = ClientConfigV1Alpha1.list() - if output == OutputMode.JSON: - click.echo(ClientConfigListV1Alpha1(current_config=current_name, items=configs).dump_json()) - elif output == OutputMode.YAML: - click.echo(ClientConfigListV1Alpha1(current_config=current_name, items=configs).dump_yaml()) - elif output == OutputMode.NAME: - if len(configs) > 0: - click.echo(configs[0].alias) - else: - columns = ["CURRENT", "NAME", "ENDPOINT", "PATH"] - - def make_row(c: ClientConfigV1Alpha1): - return { - "CURRENT": "*" if current_name == c.alias else "", - "NAME": c.alias, - "ENDPOINT": c.endpoint, - "PATH": str(c.path), - } - - rows = list(map(make_row, configs)) - click.echo(make_table(columns, rows)) + model_print(configs, output) @config_client.command("use", short_help="Select the current client config.") diff --git a/packages/jumpstarter-cli/jumpstarter_cli/config_exporter.py b/packages/jumpstarter-cli/jumpstarter_cli/config_exporter.py index a27c1f8c4..faba94171 100644 --- a/packages/jumpstarter-cli/jumpstarter_cli/config_exporter.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/config_exporter.py @@ -6,9 +6,9 @@ opt_output_all, opt_output_path_only, ) -from jumpstarter_cli_common.table import make_table +from jumpstarter_cli_common.print import model_print -from jumpstarter.config.exporter import ExporterConfigListV1Alpha1, ExporterConfigV1Alpha1, ObjectMeta +from jumpstarter.config.exporter import ExporterConfigV1Alpha1, ObjectMeta arg_alias = click.argument("alias", default="default") @@ -79,20 +79,4 @@ def list_exporter_configs(output: OutputType): """List exporter configs.""" exporters = ExporterConfigV1Alpha1.list() - if output == OutputMode.JSON: - click.echo(ExporterConfigListV1Alpha1(items=exporters).dump_json()) - elif output == OutputMode.YAML: - click.echo(ExporterConfigListV1Alpha1(items=exporters).dump_yaml()) - elif output == OutputMode.NAME: - if len(exporters) > 0: - click.echo(exporters[0].alias) - else: - columns = ["ALIAS", "PATH"] - rows = [ - { - "ALIAS": exporter.alias, - "PATH": str(exporter.path), - } - for exporter in exporters - ] - click.echo(make_table(columns, rows)) + model_print(exporters, output) diff --git a/packages/jumpstarter-cli/jumpstarter_cli/create.py b/packages/jumpstarter-cli/jumpstarter_cli/create.py index 1d3ff6585..e0a59dd25 100644 --- a/packages/jumpstarter-cli/jumpstarter_cli/create.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/create.py @@ -3,8 +3,8 @@ import click from jumpstarter_cli_common.config import opt_config from jumpstarter_cli_common.exceptions import handle_exceptions -from jumpstarter_cli_common.opt import OutputMode, OutputType, opt_output_all -from jumpstarter_cli_common.table import make_table +from jumpstarter_cli_common.opt import OutputType, opt_output_all +from jumpstarter_cli_common.print import model_print from .common import opt_duration_partial, opt_selector @@ -50,22 +50,4 @@ def create_lease(config, selector: str, duration: timedelta, output: OutputType) lease = config.create_lease(selector=selector, duration=duration) - match output: - case OutputMode.JSON: - click.echo(lease.dump_json()) - case OutputMode.YAML: - click.echo(lease.dump_yaml()) - case OutputMode.NAME: - click.echo(lease.name) - case _: - columns = ["NAME", "SELECTOR", "DURATION", "CLIENT", "EXPORTER"] - rows = [ - { - "NAME": lease.name, - "SELECTOR": lease.selector, - "DURATION": str(lease.duration.total_seconds()), - "CLIENT": lease.client, - "EXPORTER": lease.exporter, - } - ] - click.echo(make_table(columns, rows)) + model_print(lease, output) diff --git a/packages/jumpstarter-cli/jumpstarter_cli/get.py b/packages/jumpstarter-cli/jumpstarter_cli/get.py index 6353a3d79..247d3abe5 100644 --- a/packages/jumpstarter-cli/jumpstarter_cli/get.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/get.py @@ -1,8 +1,8 @@ import click from jumpstarter_cli_common.config import opt_config from jumpstarter_cli_common.exceptions import handle_exceptions -from jumpstarter_cli_common.opt import OutputMode, OutputType, opt_output_all -from jumpstarter_cli_common.table import make_table +from jumpstarter_cli_common.opt import OutputType, opt_output_all +from jumpstarter_cli_common.print import model_print from .common import opt_selector @@ -26,24 +26,7 @@ def get_exporters(config, selector: str | None, output: OutputType): exporters = config.list_exporters(filter=selector) - match output: - case OutputMode.JSON: - click.echo(exporters.dump_json()) - case OutputMode.YAML: - click.echo(exporters.dump_yaml()) - case OutputMode.NAME: - for exporter in exporters.exporters: - click.echo(exporter.name) - case _: - columns = ["NAME", "LABELS"] - rows = [ - { - "NAME": exporter.name, - "LABELS": ",".join(("{}={}".format(i[0], i[1]) for i in exporter.labels.items())), - } - for exporter in exporters.exporters - ] - click.echo(make_table(columns, rows)) + model_print(exporters, output) @get.command(name="leases") @@ -58,22 +41,4 @@ def get_leases(config, selector: str | None, output: OutputType): leases = config.list_leases(filter=selector) - match output: - case OutputMode.JSON: - click.echo(leases.dump_json()) - case OutputMode.YAML: - click.echo(leases.dump_yaml()) - case OutputMode.NAME: - for lease in leases.leases: - click.echo(lease.name) - case _: - columns = ["NAME", "CLIENT", "EXPORTER"] - rows = [ - { - "NAME": lease.name, - "CLIENT": lease.client, - "EXPORTER": lease.exporter, - } - for lease in leases.leases - ] - click.echo(make_table(columns, rows)) + model_print(leases, output) diff --git a/packages/jumpstarter-cli/jumpstarter_cli/update.py b/packages/jumpstarter-cli/jumpstarter_cli/update.py index d0f269f51..761d65e1f 100644 --- a/packages/jumpstarter-cli/jumpstarter_cli/update.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/update.py @@ -3,8 +3,8 @@ import click from jumpstarter_cli_common.config import opt_config from jumpstarter_cli_common.exceptions import handle_exceptions -from jumpstarter_cli_common.opt import OutputMode, OutputType, opt_output_all -from jumpstarter_cli_common.table import make_table +from jumpstarter_cli_common.opt import OutputType, opt_output_all +from jumpstarter_cli_common.print import model_print from .common import opt_duration_partial @@ -29,22 +29,4 @@ def update_lease(config, name: str, duration: timedelta, output: OutputType): lease = config.update_lease(name, duration) - match output: - case OutputMode.JSON: - click.echo(lease.dump_json()) - case OutputMode.YAML: - click.echo(lease.dump_yaml()) - case OutputMode.NAME: - click.echo(lease.name) - case _: - columns = ["NAME", "SELECTOR", "DURATION", "CLIENT", "EXPORTER"] - rows = [ - { - "NAME": lease.name, - "SELECTOR": lease.selector, - "DURATION": str(lease.duration.total_seconds()), - "CLIENT": lease.client, - "EXPORTER": lease.exporter, - } - ] - click.echo(make_table(columns, rows)) + model_print(lease, output) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py index c97e5c7a8..791443452 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py @@ -53,6 +53,21 @@ def from_dict(dict: dict): else None, ) + @classmethod + def rich_add_columns(cls, table): + table.add_column("NAME") + table.add_column("ENDPOINT") + # table.add_column("AGE") + + def rich_add_rows(self, table): + table.add_row( + self.metadata.name, + self.status.endpoint if self.status is not None else "", + ) + + def rich_add_names(self, names): + names.append(f"client.jumpstarter.dev/{self.metadata.name}") + class V1Alpha1ClientList(V1Alpha1List[V1Alpha1Client]): kind: Literal["ClientList"] = Field(default="ClientList") @@ -61,6 +76,18 @@ class V1Alpha1ClientList(V1Alpha1List[V1Alpha1Client]): def from_dict(dict: dict): return V1Alpha1ClientList(items=[V1Alpha1Client.from_dict(c) for c in dict.get("items", [])]) + @classmethod + def rich_add_columns(cls, table): + V1Alpha1Client.rich_add_columns(table) + + def rich_add_rows(self, table): + for client in self.items: + client.rich_add_rows(table) + + def rich_add_names(self, names): + for client in self.items: + client.rich_add_names(names) + class ClientsV1Alpha1Api(AbstractAsyncCustomObjectApi): """Interact with the clients custom resource API""" diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/time.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/datetime.py similarity index 100% rename from packages/jumpstarter-cli-common/jumpstarter_cli_common/time.py rename to packages/jumpstarter-kubernetes/jumpstarter_kubernetes/datetime.py diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py index a88f4877b..b83495812 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py @@ -5,6 +5,7 @@ from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference from pydantic import Field +from .datetime import time_since from .json import JsonBaseModel from .list import V1Alpha1List from .serialize import SerializeV1ObjectMeta, SerializeV1ObjectReference @@ -57,6 +58,47 @@ def from_dict(dict: dict): ), ) + @classmethod + def rich_add_columns(cls, table, devices: bool = False): + if devices: + table.add_column("NAME") + table.add_column("ENDPOINT") + table.add_column("AGE") + table.add_column("LABELS") + table.add_column("UUID") + else: + table.add_column("NAME") + table.add_column("ENDPOINT") + table.add_column("DEVICES") + table.add_column("AGE") + + def rich_add_rows(self, table, devices: bool = False): + if devices: + if self.status is not None: + for d in self.status.devices: + labels = [] + if d.labels is not None: + for label in d.labels: + labels.append(f"{label}:{str(d.labels[label])}") + table.add_row( + self.metadata.name, + self.status.endpoint, + time_since(self.metadata.creation_timestamp), + ",".join(labels), + d.uuid, + ) + + else: + table.add_row( + self.metadata.name, + self.status.endpoint, + str(len(self.status.devices) if self.status and self.status.devices else 0), + time_since(self.metadata.creation_timestamp), + ) + + def rich_add_names(self, names): + names.append(f"exporter.jumpstarter.dev/{self.metadata.name}") + class V1Alpha1ExporterList(V1Alpha1List[V1Alpha1Exporter]): kind: Literal["ExporterList"] = Field(default="ExporterList") @@ -65,6 +107,18 @@ class V1Alpha1ExporterList(V1Alpha1List[V1Alpha1Exporter]): def from_dict(dict: dict): return V1Alpha1ExporterList(items=[V1Alpha1Exporter.from_dict(c) for c in dict["items"]]) + @classmethod + def rich_add_columns(cls, table, **kwargs): + V1Alpha1Exporter.rich_add_columns(table, **kwargs) + + def rich_add_rows(self, table, **kwargs): + for exporter in self.items: + exporter.rich_add_rows(table, **kwargs) + + def rich_add_names(self, names): + for exporter in self.items: + exporter.rich_add_names(names) + class ExportersV1Alpha1Api(AbstractAsyncCustomObjectApi): """Interact with the exporters custom resource API""" diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py index 67361684c..3f6aa9ca3 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py @@ -3,6 +3,7 @@ from kubernetes_asyncio.client.models import V1Condition, V1ObjectMeta, V1ObjectReference from pydantic import Field +from .datetime import time_since from .json import JsonBaseModel from .list import V1Alpha1List from .serialize import SerializeV1Condition, SerializeV1ObjectMeta, SerializeV1ObjectReference @@ -76,6 +77,56 @@ def from_dict(dict: dict): ), ) + @classmethod + def rich_add_columns(cls, table): + table.add_column("NAME") + table.add_column("CLIENT") + table.add_column("SELECTOR") + table.add_column("EXPORTER") + table.add_column("DURATION") + table.add_column("STATUS") + table.add_column("REASON") + table.add_column("BEGIN") + table.add_column("END") + table.add_column("AGE") + + def get_reason(self): + condition = self.status.conditions[-1] if len(self.status.conditions) > 0 else None + reason = condition.reason if condition is not None else "Unknown" + status = condition.status if condition is not None else "False" + if reason == "Ready": + if status == "True": + return "Ready" + else: + return "Waiting" + elif reason == "Expired": + if status == "True": + return "Expired" + else: + return "Complete" + else: + return reason + + def rich_add_rows(self, table): + selectors = [] + for label in self.spec.selector.match_labels: + selectors.append(f"{label}:{str(self.spec.selector.match_labels[label])}") + table.add_row( + self.metadata.name, + self.spec.client.name if self.spec.client is not None else "", + ",".join(selectors) if len(selectors) > 0 else "*", + self.status.exporter.name if self.status.exporter is not None else "", + self.spec.duration, + "Ended" if self.status.ended else "InProgress", + self.get_reason(), + self.status.begin_time if self.status.begin_time is not None else "", + self.status.end_time if self.status.end_time is not None else "", + time_since(self.metadata.creation_timestamp), + ) + + def rich_add_names(self, names): + names.append(f"lease.jumpstarter.dev/{self.metadata.name}") + class V1Alpha1LeaseList(V1Alpha1List[V1Alpha1Lease]): kind: Literal["LeaseList"] = Field(default="LeaseList") @@ -84,6 +135,18 @@ class V1Alpha1LeaseList(V1Alpha1List[V1Alpha1Lease]): def from_dict(dict: dict): return V1Alpha1LeaseList(items=[V1Alpha1Lease.from_dict(c) for c in dict["items"]]) + @classmethod + def rich_add_columns(cls, table): + V1Alpha1Lease.rich_add_columns(table) + + def rich_add_rows(self, table): + for lease in self.items: + lease.rich_add_rows(table) + + def rich_add_names(self, names): + for lease in self.items: + lease.rich_add_names(names) + class LeasesV1Alpha1Api(AbstractAsyncCustomObjectApi): """Interact with the leases custom resource API""" diff --git a/packages/jumpstarter/jumpstarter/client/grpc.py b/packages/jumpstarter/jumpstarter/client/grpc.py index e627f41df..da0578ee0 100644 --- a/packages/jumpstarter/jumpstarter/client/grpc.py +++ b/packages/jumpstarter/jumpstarter/client/grpc.py @@ -6,7 +6,6 @@ from types import SimpleNamespace from typing import Any -import yaml from google.protobuf import duration_pb2, field_mask_pb2, json_format from grpc import ChannelConnectivity from grpc.aio import Channel @@ -49,6 +48,20 @@ def from_protobuf(cls, data: client_pb2.Exporter) -> Exporter: namespace, name = parse_exporter_identifier(data.name) return cls(namespace=namespace, name=name, labels=data.labels) + @classmethod + def rich_add_columns(cls, table): + table.add_column("NAME") + table.add_column("LABELS") + + def rich_add_rows(self, table): + table.add_row( + self.name, + ",".join(("{}={}".format(i[0], i[1]) for i in self.labels.items())), + ) + + def rich_add_names(self, names): + names.append(self.name) + class Lease(BaseModel): namespace: str @@ -96,11 +109,25 @@ def from_protobuf(cls, data: client_pb2.Lease) -> Lease: conditions=data.conditions, ) - def dump_json(self): - return self.model_dump_json(indent=4, by_alias=True) + @classmethod + def rich_add_columns(cls, table): + table.add_column("NAME") + table.add_column("SELECTOR") + table.add_column("DURATION") + table.add_column("CLIENT") + table.add_column("EXPORTER") + + def rich_add_rows(self, table): + table.add_row( + self.name, + self.selector, + str(self.duration), + self.client, + self.exporter, + ) - def dump_yaml(self): - return yaml.safe_dump(self.model_dump(mode="json", by_alias=True), indent=2) + def rich_add_names(self, names): + names.append(self.name) class ExporterList(BaseModel): @@ -114,11 +141,17 @@ def from_protobuf(cls, data: client_pb2.ListExportersResponse) -> ExporterList: next_page_token=data.next_page_token, ) - def dump_json(self): - return self.model_dump_json(indent=4, by_alias=True) + @classmethod + def rich_add_columns(cls, table): + Exporter.rich_add_columns(table) + + def rich_add_rows(self, table): + for exporter in self.exporters: + exporter.rich_add_rows(table) - def dump_yaml(self): - return yaml.safe_dump(self.model_dump(mode="json", by_alias=True), indent=2) + def rich_add_names(self, names): + for exporter in self.exporters: + exporter.rich_add_names(names) class LeaseList(BaseModel): @@ -132,11 +165,17 @@ def from_protobuf(cls, data: client_pb2.ListLeasesResponse) -> LeaseList: next_page_token=data.next_page_token, ) - def dump_json(self): - return self.model_dump_json(indent=4, by_alias=True) + @classmethod + def rich_add_columns(cls, table): + Lease.rich_add_columns(table) + + def rich_add_rows(self, table): + for lease in self.leases: + lease.rich_add_rows(table) - def dump_yaml(self): - return yaml.safe_dump(self.model_dump(mode="json", by_alias=True), indent=2) + def rich_add_names(self, names): + for lease in self.leases: + lease.rich_add_names(names) @dataclass(kw_only=True, slots=True) diff --git a/packages/jumpstarter/jumpstarter/config/client.py b/packages/jumpstarter/jumpstarter/config/client.py index 855de61fb..d149bf260 100644 --- a/packages/jumpstarter/jumpstarter/config/client.py +++ b/packages/jumpstarter/jumpstarter/config/client.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import os from contextlib import asynccontextmanager, contextmanager @@ -9,7 +11,7 @@ import grpc import yaml from anyio.from_thread import BlockingPortal, start_blocking_portal -from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict from .common import CONFIG_PATH, ObjectMeta @@ -252,11 +254,16 @@ def exists(cls, alias: str) -> bool: return cls._get_path(alias).exists() @classmethod - def list(cls) -> list[Self]: + def list(cls) -> ClientConfigListV1Alpha1: """List the available client configs.""" + from .user import UserConfigV1Alpha1 + if cls.CLIENT_CONFIGS_PATH.exists() is False: # Return an empty list if the dir does not exist - return [] + return ClientConfigListV1Alpha1( + current_config=None, + items=[], + ) results = os.listdir(cls.CLIENT_CONFIGS_PATH) # Only accept YAML files in the list @@ -266,7 +273,15 @@ def make_config(file: str): path = cls.CLIENT_CONFIGS_PATH / file return cls.from_file(path) - return list(map(make_config, files)) + current_config = None + if UserConfigV1Alpha1.exists(): + current_client = UserConfigV1Alpha1.load().config.current_client + current_config = current_client.alias if current_client is not None else None + + return ClientConfigListV1Alpha1( + current_config=current_config, + items=list(map(make_config, files)), + ) @classmethod def delete(cls, alias: str) -> Path: @@ -290,4 +305,24 @@ def dump_json(self): def dump_yaml(self): return yaml.safe_dump(self.model_dump(mode="json", by_alias=True), indent=2) - model_config = SettingsConfigDict(arbitrary_types_allowed=True, populate_by_name=True) + model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) + + @classmethod + def rich_add_columns(cls, table): + table.add_column("CURRENT") + table.add_column("ALIAS") + table.add_column("ENDPOINT") + table.add_column("PATH") + + def rich_add_rows(self, table): + for client in self.items: + table.add_row( + "*" if self.current_config == client.alias else "", + client.alias, + client.endpoint, + str(client.path), + ) + + def rich_add_names(self, names): + for client in self.items: + names.append(client.alias) diff --git a/packages/jumpstarter/jumpstarter/config/client_config_test.py b/packages/jumpstarter/jumpstarter/config/client_config_test.py index 1f172198c..af9bd3735 100644 --- a/packages/jumpstarter/jumpstarter/config/client_config_test.py +++ b/packages/jumpstarter/jumpstarter/config/client_config_test.py @@ -324,7 +324,7 @@ def test_client_config_list(monkeypatch: pytest.MonkeyPatch): f.close() monkeypatch.setattr(ClientConfigV1Alpha1, "CLIENT_CONFIGS_PATH", Path(d)) - configs = ClientConfigV1Alpha1.list() + configs = ClientConfigV1Alpha1.list().items assert len(configs) == 1 assert configs[0].alias == "testclient" @@ -332,13 +332,13 @@ def test_client_config_list(monkeypatch: pytest.MonkeyPatch): def test_client_config_list_none(monkeypatch: pytest.MonkeyPatch): with tempfile.TemporaryDirectory() as d: monkeypatch.setattr(ClientConfigV1Alpha1, "CLIENT_CONFIGS_PATH", Path(d)) - configs = ClientConfigV1Alpha1.list() + configs = ClientConfigV1Alpha1.list().items assert len(configs) == 0 def test_client_config_list_not_found_returns_empty(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(ClientConfigV1Alpha1, "CLIENT_CONFIGS_PATH", Path("/homeless-shelter")) - configs = ClientConfigV1Alpha1.list() + configs = ClientConfigV1Alpha1.list().items assert len(configs) == 0 diff --git a/packages/jumpstarter/jumpstarter/config/exporter.py b/packages/jumpstarter/jumpstarter/config/exporter.py index 3ec4f4ddf..c7431aea6 100644 --- a/packages/jumpstarter/jumpstarter/config/exporter.py +++ b/packages/jumpstarter/jumpstarter/config/exporter.py @@ -110,12 +110,12 @@ def load(cls, alias: str) -> Self: return config @classmethod - def list(cls) -> list[Self]: + def list(cls) -> ExporterConfigListV1Alpha1: exporters = [] with suppress(FileNotFoundError): for entry in cls.BASE_PATH.iterdir(): exporters.append(cls.load(entry.stem)) - return exporters + return ExporterConfigListV1Alpha1(items=exporters) @classmethod def dump_yaml(self, config: Self) -> str: @@ -184,10 +184,20 @@ class ExporterConfigListV1Alpha1(BaseModel): items: list[ExporterConfigV1Alpha1] kind: Literal["ExporterConfigList"] = Field(default="ExporterConfigList") - def dump_json(self): - return self.model_dump_json(indent=4, by_alias=True) + model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) - def dump_yaml(self): - return yaml.safe_dump(self.model_dump(mode="json", by_alias=True), indent=2) + @classmethod + def rich_add_columns(cls, table): + table.add_column("ALIAS") + table.add_column("PATH") + + def rich_add_rows(self, table): + for exporter in self.items: + table.add_row( + exporter.alias, + str(exporter.path), + ) - model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) + def rich_add_names(self, names): + for exporter in self.items: + names.append(exporter.alias)