diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py index cbd95667b..0e4829e01 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py @@ -4,15 +4,15 @@ import asyncclick as click from jumpstarter_cli_common import ( AliasedGroup, - OutputMode, OutputType, + echo, opt_context, opt_kubeconfig, opt_labels, opt_log_level, opt_namespace, opt_nointeractive, - opt_output_all, + opt_output_auto, ) from jumpstarter_kubernetes import ClientsV1Alpha1Api, ExportersV1Alpha1Api, V1Alpha1Client, V1Alpha1Exporter from kubernetes_asyncio.client.exceptions import ApiException @@ -38,12 +38,8 @@ def create(log_level: Optional[str]): 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}") + if output is not None: + echo(client.dump(output)) @create.command("client") @@ -75,7 +71,7 @@ def print_created_client(client: V1Alpha1Client, output: OutputType): @opt_context @opt_oidc_username @opt_nointeractive -@opt_output_all +@opt_output_auto(V1Alpha1Client) async def create_client( name: Optional[str], kubeconfig: Optional[str], @@ -127,12 +123,8 @@ async def create_client( 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}") + if output is not None: + echo(exporter.dump(output)) @create.command("exporter") @@ -156,7 +148,7 @@ def print_created_exporter(exporter: V1Alpha1Exporter, output: OutputType): @opt_context @opt_oidc_username @opt_nointeractive -@opt_output_all +@opt_output_auto(V1Alpha1Exporter) async def create_exporter( name: Optional[str], kubeconfig: Optional[str], diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py index 5781321d2..b9570d790 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py @@ -66,7 +66,6 @@ credential: name: {name}-credential endpoint: {endpoint} - """.format(name=CLIENT_NAME, endpoint=CLIENT_ENDPOINT) UNSAFE_CLIENT_CONFIG = ClientConfigV1Alpha1( @@ -214,7 +213,6 @@ async def test_create_client( name: {name}-credential devices: [] endpoint: {endpoint} - """.format(name=EXPORTER_NAME, endpoint=EXPORTER_ENDPOINT) EXPORTER_CONFIG = ExporterConfigV1Alpha1( diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py index a5358b1e5..0ab0bb5d2 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py @@ -9,12 +9,15 @@ opt_kubeconfig, opt_log_level, opt_namespace, - opt_output_all, + opt_output_auto, ) from jumpstarter_kubernetes import ( ClientsV1Alpha1Api, ExportersV1Alpha1Api, LeasesV1Alpha1Api, + V1Alpha1Client, + V1Alpha1Exporter, + V1Alpha1Lease, ) from kubernetes_asyncio.client.exceptions import ApiException from kubernetes_asyncio.config.config_exception import ConfigException @@ -41,7 +44,7 @@ def get(log_level: Optional[str]): @opt_namespace @opt_kubeconfig @opt_context -@opt_output_all +@opt_output_auto(V1Alpha1Client) async def get_client( name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: OutputType ): @@ -65,7 +68,7 @@ async def get_client( @opt_namespace @opt_kubeconfig @opt_context -@opt_output_all +@opt_output_auto(V1Alpha1Exporter) @click.option("-d", "--devices", is_flag=True, help="Display the devices hosted by the exporter(s)") async def get_exporter( name: Optional[str], @@ -95,7 +98,7 @@ async def get_exporter( @opt_namespace @opt_kubeconfig @opt_context -@opt_output_all +@opt_output_auto(V1Alpha1Lease) async def get_lease( name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: OutputType ): 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 fa7521d12..d133074ed 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py @@ -75,7 +75,6 @@ def getheaders(self): credential: name: test-credential endpoint: grpc://example.com:443 - """ @@ -217,7 +216,6 @@ async def test_get_client(_load_kube_config_mock, get_client_mock: AsyncMock): name: another-credential endpoint: grpc://example.com:443 kind: ClientList - """ CLIENTS_LIST_NAME = """client.jumpstarter.dev/test @@ -227,7 +225,6 @@ async def test_get_client(_load_kube_config_mock, get_client_mock: AsyncMock): CLIENTS_LIST_EMPTY_YAML = """apiVersion: jumpstarter.dev/v1alpha1 items: [] kind: ClientList - """ @@ -334,7 +331,6 @@ async def test_get_clients(_load_kube_config_mock, list_clients_mock: AsyncMock) name: test-credential devices: [] endpoint: grpc://example.com:443 - """ @@ -448,7 +444,6 @@ async def test_get_exporter(_load_kube_config_mock, get_exporter_mock: AsyncMock hardware: rpi4 uuid: f7cd30ac-64a3-42c6-ba31-b25f033b97c1 endpoint: grpc://example.com:443 - """ @@ -592,7 +587,6 @@ async def test_get_exporter_devices(_load_kube_config_mock, get_exporter_mock: A devices: [] endpoint: grpc://example.com:443 kind: ExporterList - """ EXPORTERS_LIST_NAME = """exporter.jumpstarter.dev/test @@ -760,7 +754,6 @@ async def test_get_exporters(_load_kube_config_mock, list_exporters_mock: AsyncM uuid: f7cd30ac-64a3-42c6-ba31-b25f033b97c1 endpoint: grpc://example.com:443 kind: ExporterList - """ EXPORTERS_DEVICES_LIST_NAME = EXPORTERS_LIST_NAME @@ -936,7 +929,6 @@ async def test_get_exporters_devices(_load_kube_config_mock, list_exporters_mock ended: true exporter: name: test_exporter - """ @@ -1145,7 +1137,6 @@ async def test_get_lease(_load_kube_config_mock, get_lease_mock: AsyncMock): exporter: name: test_exporter kind: LeaseList - """ LEASES_LIST_NAME = """lease.jumpstarter.dev/82a8ac0d-d7ff-4009-8948-18a3c5c607b1 diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py index fc0a4cb38..3ca3fdbac 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/print.py @@ -1,7 +1,7 @@ import asyncclick as click from jumpstarter_cli_common import ( - OutputMode, OutputType, + echo, make_table, time_since, ) @@ -9,8 +9,8 @@ V1Alpha1Client, V1Alpha1Exporter, V1Alpha1Lease, - V1Alpha1List, ) +from jumpstarter.common.pydantic import SerializableBaseModelList CLIENT_COLUMNS = ["NAME", "ENDPOINT", "AGE"] @@ -24,27 +24,18 @@ def make_client_row(client: V1Alpha1Client): 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}") + if output: + echo(client.dump(output)) 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') +def print_clients(clients: SerializableBaseModelList[V1Alpha1Client], namespace: str, output: OutputType): + if output: + echo(clients.dump(output)) else: + if len(clients.items) == 0: + raise click.ClickException(f'No resources found in "{namespace}" namespace') click.echo(make_table(CLIENT_COLUMNS, list(map(make_client_row, clients.items)))) @@ -85,33 +76,28 @@ def get_device_rows(exporters: list[V1Alpha1Exporter]): 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]))) + if output: + echo(exporter.dump(output)) 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))) + if 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: SerializableBaseModelList[V1Alpha1Exporter], namespace: str, devices: bool, output: OutputType +): + if output: + echo(exporters.dump(output)) else: - click.echo(make_table(EXPORTER_COLUMNS, list(map(make_exporter_row, exporters.items)))) + if len(exporters.items) == 0: + raise click.ClickException(f'No resources found in "{namespace}" namespace') + if 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"] @@ -154,25 +140,16 @@ def make_lease_row(lease: V1Alpha1Lease): 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}") + if output: + echo(lease.dump(output)) 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') +def print_leases(leases: SerializableBaseModelList[V1Alpha1Lease], namespace: str, output: OutputType): + if output: + echo(leases.dump(output)) else: + if len(leases.items) == 0: + raise click.ClickException(f'No resources found in "{namespace}" namespace') click.echo(make_table(LEASE_COLUMNS, list(map(make_lease_row, leases.items)))) diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py index afd6343c8..3eac5e179 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/__init__.py @@ -1,5 +1,6 @@ from .alias import AliasedGroup from .config import opt_config +from .echo import echo from .opt import ( NameOutputType, OutputMode, @@ -12,6 +13,7 @@ opt_namespace, opt_nointeractive, opt_output_all, + opt_output_auto, opt_output_name_only, opt_output_path_only, ) @@ -21,6 +23,7 @@ __all__ = [ "AliasedGroup", + "echo", "make_table", "opt_config", "opt_context", @@ -32,6 +35,7 @@ "opt_output_all", "opt_output_name_only", "opt_output_path_only", + "opt_output_auto", "OutputMode", "OutputType", "NameOutputType", diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/echo.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/echo.py new file mode 100644 index 000000000..e78e8ed19 --- /dev/null +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/echo.py @@ -0,0 +1,5 @@ +from functools import partial + +import asyncclick as click + +echo = partial(click.echo, nl=False) diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py index cd9937bd7..a1baf5349 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py @@ -2,6 +2,10 @@ import asyncclick as click +from jumpstarter.common.pydantic import OutputMode, OutputType + +__all__ = ["OutputType"] + opt_log_level = click.option( "--log-level", "log_level", @@ -20,15 +24,6 @@ opt_labels = click.option("-l", "--label", "labels", type=(str, str), multiple=True, help="Labels") -class OutputMode(str): - JSON = "json" - YAML = "yaml" - NAME = "name" - PATH = "path" - - -OutputType = Optional[OutputMode] - opt_output_all = click.option( "-o", "--output", @@ -60,3 +55,30 @@ class OutputMode(str): opt_nointeractive = click.option( "--nointeractive", is_flag=True, default=False, help="Disable interactive prompts (for use in scripts)." ) + + +def opt_output_auto(cls): + choices = [] + if hasattr(cls, "dump_json"): + choices.append(OutputMode.JSON) + if hasattr(cls, "dump_yaml"): + choices.append(OutputMode.YAML) + if hasattr(cls, "dump_name"): + choices.append(OutputMode.NAME) + if hasattr(cls, "dump_path"): + choices.append(OutputMode.PATH) + + if OutputMode.PATH in choices: + help = 'Output mode. Use "-o path" for shorter output (file/path).' + elif OutputMode.NAME in choices: + help = 'Output mode. Use "-o name" for shorter output (resource/name).' + else: + help = "Output mode." + + return click.option( + "-o", + "--output", + type=click.Choice(choices), + default=None, + help=help, + ) diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/version.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/version.py index a2d6bdf0b..8372b6f21 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/version.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/version.py @@ -3,10 +3,11 @@ import sys import asyncclick as click -import yaml -from pydantic import BaseModel, ConfigDict, Field +from pydantic import ConfigDict, Field -from .opt import OutputMode, OutputType, opt_output_all +from .echo import echo +from .opt import OutputType, opt_output_auto +from jumpstarter.common.pydantic import SerializableBaseModel def get_client_version(): @@ -27,30 +28,22 @@ def version_msg(): return f"Jumpstarter v{jumpstarter_version} from {location} (Python {python_version})" -class JumpstarterVersion(BaseModel): +class JumpstarterVersion(SerializableBaseModel): git_version: str = Field(alias="gitVersion") python_version: str = Field(alias="pythonVersion") model_config = ConfigDict(populate_by_name=True) - def dump_json(self): - return self.model_dump_json(indent=4, by_alias=True) - - def dump_yaml(self): - return yaml.safe_dump(self.model_dump(mode="json", by_alias=True), indent=2) - def version_obj(): return JumpstarterVersion(git_version=importlib.metadata.version("jumpstarter"), python_version=sys.version) @click.command() -@opt_output_all +@opt_output_auto(JumpstarterVersion) def version(output: OutputType): """Get the current Jumpstarter version""" - if output == OutputMode.JSON: - click.echo(version_obj().dump_json()) - elif output == OutputMode.YAML: - click.echo(version_obj().dump_yaml()) + if output: + echo(version_obj().dump(output)) else: click.echo(version_msg()) diff --git a/packages/jumpstarter-cli/jumpstarter_cli/create.py b/packages/jumpstarter-cli/jumpstarter_cli/create.py index 43fba15ec..762877676 100644 --- a/packages/jumpstarter-cli/jumpstarter_cli/create.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/create.py @@ -2,15 +2,16 @@ import asyncclick as click from jumpstarter_cli_common import ( - OutputMode, OutputType, + echo, make_table, opt_config, - opt_output_all, + opt_output_auto, ) from jumpstarter_cli_common.exceptions import handle_exceptions from .common import opt_duration_partial, opt_selector +from jumpstarter.client.grpc import Lease @click.group() @@ -24,7 +25,7 @@ def create(): @opt_config(exporter=False) @opt_selector @opt_duration_partial(required=True) -@opt_output_all +@opt_output_auto(Lease) @handle_exceptions async def create_lease(config, selector: str, duration: timedelta, output: OutputType): """ @@ -54,22 +55,17 @@ async def create_lease(config, selector: str, duration: timedelta, output: Outpu 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)) + if output: + echo(lease.dump(output)) + else: + 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)) diff --git a/packages/jumpstarter-cli/jumpstarter_cli/get.py b/packages/jumpstarter-cli/jumpstarter_cli/get.py index 570f8fd4e..669077f19 100644 --- a/packages/jumpstarter-cli/jumpstarter_cli/get.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/get.py @@ -1,8 +1,9 @@ import asyncclick as click -from jumpstarter_cli_common import OutputMode, OutputType, make_table, opt_config, opt_output_all +from jumpstarter_cli_common import OutputType, make_table, opt_config, opt_output_auto from jumpstarter_cli_common.exceptions import handle_exceptions from .common import opt_selector +from jumpstarter.client.grpc import ExporterList, LeaseList @click.group() @@ -15,7 +16,7 @@ def get(): @get.command(name="exporters") @opt_config(exporter=False) @opt_selector -@opt_output_all +@opt_output_auto(ExporterList) @handle_exceptions def get_exporters(config, selector: str | None, output: OutputType): """ @@ -24,30 +25,24 @@ 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)) + if output: + click.echo(exporters.dump(output)) + else: + 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)) @get.command(name="leases") @opt_config(exporter=False) @opt_selector -@opt_output_all +@opt_output_auto(LeaseList) @handle_exceptions def get_leases(config, selector: str | None, output: OutputType): """ @@ -56,22 +51,16 @@ 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)) + if output: + click.echo(leases.dump(output)) + else: + 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)) diff --git a/packages/jumpstarter-cli/jumpstarter_cli/update.py b/packages/jumpstarter-cli/jumpstarter_cli/update.py index f4f3badbf..268e63eaa 100644 --- a/packages/jumpstarter-cli/jumpstarter_cli/update.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/update.py @@ -1,10 +1,11 @@ from datetime import timedelta import asyncclick as click -from jumpstarter_cli_common import OutputMode, OutputType, make_table, opt_config, opt_output_all +from jumpstarter_cli_common import OutputType, echo, make_table, opt_config, opt_output_auto from jumpstarter_cli_common.exceptions import handle_exceptions from .common import opt_duration_partial +from jumpstarter.client.grpc import Lease @click.group() @@ -18,7 +19,7 @@ def update(): @opt_config(exporter=False) @click.argument("name") @opt_duration_partial(required=True) -@opt_output_all +@opt_output_auto(Lease) @handle_exceptions async def update_lease(config, name: str, duration: timedelta, output: OutputType): """ @@ -27,22 +28,17 @@ async def update_lease(config, name: str, duration: timedelta, output: OutputTyp 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)) + if output: + echo(lease.dump(output)) + else: + 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)) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py index 6a9bf1b76..0e6db65b4 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py @@ -15,7 +15,6 @@ V1Alpha1LeaseSpec, V1Alpha1LeaseStatus, ) -from .list import V1Alpha1List __all__ = [ "ClientsV1Alpha1Api", @@ -33,7 +32,6 @@ "V1Alpha1LeaseList", "V1Alpha1LeaseSelector", "V1Alpha1LeaseSpec", - "V1Alpha1List", "get_ip_address", "helm_installed", "install_helm_chart", diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py index 836e44b6d..8ce69005c 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py @@ -7,9 +7,9 @@ from pydantic import Field from .json import JsonBaseModel -from .list import V1Alpha1List from .serialize import SerializeV1ObjectMeta, SerializeV1ObjectReference from .util import AbstractAsyncCustomObjectApi +from jumpstarter.common.pydantic import SerializableBaseModelList from jumpstarter.config import ClientConfigV1Alpha1, ClientConfigV1Alpha1Drivers, ObjectMeta logger = logging.getLogger(__name__) @@ -53,7 +53,7 @@ def from_dict(dict: dict): ) -class V1Alpha1ClientList(V1Alpha1List[V1Alpha1Client]): +class V1Alpha1ClientList(SerializableBaseModelList[V1Alpha1Client]): kind: Literal["ClientList"] = Field(default="ClientList") @staticmethod @@ -99,7 +99,7 @@ async def create_client( await asyncio.sleep(CREATE_CLIENT_DELAY) raise Exception("Timeout waiting for client credentials") - async def list_clients(self) -> V1Alpha1List[V1Alpha1Client]: + async def list_clients(self) -> SerializableBaseModelList[V1Alpha1Client]: """List the client objects in the cluster async""" res = await self.api.list_namespaced_custom_object( namespace=self.namespace, group="jumpstarter.dev", plural="clients", version="v1alpha1" diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py index d42383294..b9d534c05 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py @@ -35,7 +35,8 @@ def test_client_dump_json(): "credential": null, "endpoint": "https://test-client" } -}""" +} +""" ) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py index 56cb3af84..95077d996 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py @@ -6,9 +6,9 @@ from pydantic import Field from .json import JsonBaseModel -from .list import V1Alpha1List from .serialize import SerializeV1ObjectMeta, SerializeV1ObjectReference from .util import AbstractAsyncCustomObjectApi +from jumpstarter.common.pydantic import SerializableBaseModelList from jumpstarter.config import ExporterConfigV1Alpha1, ObjectMeta CREATE_EXPORTER_DELAY = 1 @@ -57,7 +57,7 @@ def from_dict(dict: dict): ) -class V1Alpha1ExporterList(V1Alpha1List[V1Alpha1Exporter]): +class V1Alpha1ExporterList(SerializableBaseModelList[V1Alpha1Exporter]): kind: Literal["ExporterList"] = Field(default="ExporterList") @staticmethod @@ -68,7 +68,7 @@ def from_dict(dict: dict): class ExportersV1Alpha1Api(AbstractAsyncCustomObjectApi): """Interact with the exporters custom resource API""" - async def list_exporters(self) -> V1Alpha1List[V1Alpha1Exporter]: + async def list_exporters(self) -> SerializableBaseModelList[V1Alpha1Exporter]: """List the exporter objects in the cluster""" res = await self.api.list_namespaced_custom_object( namespace=self.namespace, group="jumpstarter.dev", plural="exporters", version="v1alpha1" diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py index 78104c0ad..5aef2474c 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py @@ -49,7 +49,8 @@ def test_exporter_dump_json(): ], "endpoint": "https://test-exporter" } -}""" +} +""" ) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/json.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/json.py index 872c6696e..9aafefcc9 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/json.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/json.py @@ -1,14 +1,12 @@ -import yaml -from pydantic import BaseModel, ConfigDict +from pydantic import ConfigDict +from jumpstarter.common.pydantic import SerializableBaseModel -class JsonBaseModel(BaseModel): - """A Pydantic BaseModel with additional Jumpstarter JSON options applied.""" - def dump_json(self): - return self.model_dump_json(indent=4, by_alias=True) +class JsonBaseModel(SerializableBaseModel): + """A Pydantic BaseModel with additional Jumpstarter JSON options applied.""" - def dump_yaml(self): - return yaml.safe_dump(self.model_dump(mode="json", by_alias=True), indent=2) + def dump_name(self): + return "{}.jumpstarter.dev/{}\n".format(self.kind.lower(), self.metadata.name) model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py index 67361684c..f4143a36f 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py @@ -4,9 +4,9 @@ from pydantic import Field from .json import JsonBaseModel -from .list import V1Alpha1List from .serialize import SerializeV1Condition, SerializeV1ObjectMeta, SerializeV1ObjectReference from .util import AbstractAsyncCustomObjectApi +from jumpstarter.common.pydantic import SerializableBaseModelList class V1Alpha1LeaseStatus(JsonBaseModel): @@ -77,7 +77,7 @@ def from_dict(dict: dict): ) -class V1Alpha1LeaseList(V1Alpha1List[V1Alpha1Lease]): +class V1Alpha1LeaseList(SerializableBaseModelList[V1Alpha1Lease]): kind: Literal["LeaseList"] = Field(default="LeaseList") @staticmethod @@ -88,7 +88,7 @@ def from_dict(dict: dict): class LeasesV1Alpha1Api(AbstractAsyncCustomObjectApi): """Interact with the leases custom resource API""" - async def list_leases(self) -> V1Alpha1List[V1Alpha1Lease]: + async def list_leases(self) -> SerializableBaseModelList[V1Alpha1Lease]: """List the lease objects in the cluster async""" result = await self.api.list_namespaced_custom_object( namespace=self.namespace, group="jumpstarter.dev", plural="leases", version="v1alpha1" diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py deleted file mode 100644 index 3c8dc41d1..000000000 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import Generic, Literal, TypeVar - -from pydantic import Field - -from .json import JsonBaseModel - -T = TypeVar("T") - - -class V1Alpha1List(JsonBaseModel, Generic[T]): - """A generic list result type.""" - - api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") - items: list[T] - kind: Literal["List"] = Field(default="List") diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py index ef523d38c..2cb2d07be 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py @@ -76,7 +76,8 @@ def test_lease_dump_json(): "name": "test-exporter" } } -}""" +} +""" ) diff --git a/packages/jumpstarter/jumpstarter/client/grpc.py b/packages/jumpstarter/jumpstarter/client/grpc.py index 3ea50856c..84f2a2359 100644 --- a/packages/jumpstarter/jumpstarter/client/grpc.py +++ b/packages/jumpstarter/jumpstarter/client/grpc.py @@ -3,13 +3,13 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta -import yaml from google.protobuf import duration_pb2, field_mask_pb2, json_format from grpc.aio import Channel from jumpstarter_protocol import client_pb2, client_pb2_grpc, kubernetes_pb2 -from pydantic import BaseModel, ConfigDict, Field, field_serializer +from pydantic import ConfigDict, Field, field_serializer from jumpstarter.common.grpc import translate_grpc_exceptions +from jumpstarter.common.pydantic import SerializableBaseModel def parse_identifier(identifier: str, kind: str) -> (str, str): @@ -35,9 +35,15 @@ def parse_lease_identifier(identifier: str) -> (str, str): return parse_identifier(identifier, "leases") -class Exporter(BaseModel): +class GrpcObject(SerializableBaseModel): namespace: str name: str + + def dump_name(self) -> str: + return self.name + "\n" + + +class Exporter(GrpcObject): labels: dict[str, str] @classmethod @@ -46,9 +52,7 @@ def from_protobuf(cls, data: client_pb2.Exporter) -> Exporter: return cls(namespace=namespace, name=name, labels=data.labels) -class Lease(BaseModel): - namespace: str - name: str +class Lease(GrpcObject): selector: str duration: timedelta client: str @@ -92,17 +96,14 @@ 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) - - def dump_yaml(self): - return yaml.safe_dump(self.model_dump(mode="json", by_alias=True), indent=2) - -class ExporterList(BaseModel): +class ExporterList(SerializableBaseModel): exporters: list[Exporter] next_page_token: str | None = Field(exclude=True) + def dump_name(self) -> str: + return "".join(exporter.dump_name() for exporter in self.exporters) + @classmethod def from_protobuf(cls, data: client_pb2.ListExportersResponse) -> ExporterList: return cls( @@ -110,17 +111,14 @@ 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) - - def dump_yaml(self): - return yaml.safe_dump(self.model_dump(mode="json", by_alias=True), indent=2) - -class LeaseList(BaseModel): +class LeaseList(SerializableBaseModel): leases: list[Lease] next_page_token: str | None = Field(exclude=True) + def dump_name(self) -> str: + return "".join(lease.dump_name() for lease in self.leases) + @classmethod def from_protobuf(cls, data: client_pb2.ListLeasesResponse) -> LeaseList: return cls( @@ -128,12 +126,6 @@ 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) - - def dump_yaml(self): - return yaml.safe_dump(self.model_dump(mode="json", by_alias=True), indent=2) - @dataclass(kw_only=True, slots=True) class ClientService: diff --git a/packages/jumpstarter/jumpstarter/common/pydantic.py b/packages/jumpstarter/jumpstarter/common/pydantic.py new file mode 100644 index 000000000..2453ffe45 --- /dev/null +++ b/packages/jumpstarter/jumpstarter/common/pydantic.py @@ -0,0 +1,49 @@ +from typing import Generic, Literal, TypeVar + +import yaml +from pydantic import BaseModel, Field + +T = TypeVar("T") + + +class OutputMode(str): + JSON = "json" + YAML = "yaml" + NAME = "name" + PATH = "path" + + +OutputType = OutputMode | None + + +class SerializableBaseModel(BaseModel): + def dump(self, mode: OutputType = None): + match mode: + case OutputMode.JSON: + return self.dump_json() + case OutputMode.YAML: + return self.dump_yaml() + case OutputMode.NAME: + return self.dump_name() + case OutputMode.PATH: + return self.dump_path() + case None | _: + raise NotImplementedError("unimplemented output mode: {}".format(mode)) + + def dump_json(self) -> str: + return self.model_dump_json(indent=4, by_alias=True) + "\n" + + def dump_yaml(self) -> str: + return yaml.safe_dump(self.model_dump(mode="json", by_alias=True), indent=2) + + +class SerializableBaseModelList(SerializableBaseModel, Generic[T]): + api_version: Literal["jumpstarter.dev/v1alpha1"] = Field( + alias="apiVersion", + default="jumpstarter.dev/v1alpha1", + ) + kind: Literal["List"] = Field(default="List") + items: list[T] + + def dump_name(self): + return "".join(item.dump_name() for item in self.items) diff --git a/packages/jumpstarter/jumpstarter/config/client.py b/packages/jumpstarter/jumpstarter/config/client.py index 6c1841d28..d1c9daa85 100644 --- a/packages/jumpstarter/jumpstarter/config/client.py +++ b/packages/jumpstarter/jumpstarter/config/client.py @@ -16,6 +16,7 @@ from jumpstarter.client.grpc import ClientService from jumpstarter.common.exceptions import FileNotFoundError from jumpstarter.common.grpc import aio_secure_channel, ssl_channel_credentials +from jumpstarter.common.pydantic import SerializableBaseModel def _allow_from_env(): @@ -274,16 +275,10 @@ def delete(cls, alias: str) -> Path: return path -class ClientConfigListV1Alpha1(BaseModel): +class ClientConfigListV1Alpha1(SerializableBaseModel): api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") current_config: Optional[str] = Field(alias="currentConfig") items: list[ClientConfigV1Alpha1] kind: Literal["ClientConfigList"] = Field(default="ClientConfigList") - def dump_json(self): - return self.model_dump_json(indent=4, by_alias=True) - - def dump_yaml(self): - return yaml.safe_dump(self.model_dump(mode="json", by_alias=True), indent=2) - model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) diff --git a/packages/jumpstarter/jumpstarter/config/exporter.py b/packages/jumpstarter/jumpstarter/config/exporter.py index d8e2f45f0..13eebaf87 100644 --- a/packages/jumpstarter/jumpstarter/config/exporter.py +++ b/packages/jumpstarter/jumpstarter/config/exporter.py @@ -14,6 +14,7 @@ from .tls import TLSConfigV1Alpha1 from jumpstarter.common.grpc import aio_secure_channel, ssl_channel_credentials from jumpstarter.common.importlib import import_class +from jumpstarter.common.pydantic import SerializableBaseModel from jumpstarter.driver import Driver @@ -175,15 +176,9 @@ async def channel_factory(): await exporter.serve() -class ExporterConfigListV1Alpha1(BaseModel): +class ExporterConfigListV1Alpha1(SerializableBaseModel): api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") items: list[ExporterConfigV1Alpha1] kind: Literal["ExporterConfigList"] = Field(default="ExporterConfigList") - def dump_json(self): - return self.model_dump_json(indent=4, by_alias=True) - - def dump_yaml(self): - return yaml.safe_dump(self.model_dump(mode="json", by_alias=True), indent=2) - model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True)