From 7b0b0e6d4bac97cde3b88e35d5abb5bbb9743c11 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Fri, 23 Feb 2024 17:24:24 +0800 Subject: [PATCH] refactor(cli): make CLI commands available as modules (#4487) * feat(cli): make CLI commands avaiable as modules Signed-off-by: Frost Ming * fix: handle bentoml group Signed-off-by: Frost Ming * fix: simply aliases Signed-off-by: Frost Ming * fix: rename bento_command Signed-off-by: Frost Ming --------- Signed-off-by: Frost Ming --- Makefile | 2 + src/bentoml_cli/bentos.py | 77 +- src/bentoml_cli/cli.py | 41 +- src/bentoml_cli/cloud.py | 149 ++-- src/bentoml_cli/containerize.py | 482 ++++++------ src/bentoml_cli/deployment.py | 1290 +++++++++++++++---------------- src/bentoml_cli/env.py | 127 ++- src/bentoml_cli/models.py | 584 +++++++------- src/bentoml_cli/serve.py | 15 +- src/bentoml_cli/start.py | 12 +- src/bentoml_cli/utils.py | 205 ++--- 11 files changed, 1539 insertions(+), 1445 deletions(-) diff --git a/Makefile b/Makefile index 90cbe1327ce..3e5d3d991b8 100644 --- a/Makefile +++ b/Makefile @@ -41,6 +41,8 @@ clean: ## Clean all generated files @find . -type f -name '*.py[co]' -delete -o -type d -name __pycache__ -delete # Docs +watch-docs: ## Build and watch documentation + pdm run sphinx-autobuild docs/source docs/build/html --watch $(GIT_ROOT)/src/ --ignore "bazel-*" spellcheck-docs: ## Spell check documentation pdm run sphinx-build -b spelling ./docs/source ./docs/build || (echo "Error running spellchecker.. You may need to run 'make install-spellchecker-deps'"; exit 1) OS := $(shell uname) diff --git a/src/bentoml_cli/bentos.py b/src/bentoml_cli/bentos.py index d0470b33986..1ad99767c5f 100644 --- a/src/bentoml_cli/bentos.py +++ b/src/bentoml_cli/bentos.py @@ -18,7 +18,6 @@ if t.TYPE_CHECKING: from click import Context - from click import Group from click import Parameter from bentoml._internal.bento import BentoStore @@ -60,7 +59,7 @@ def parse_delete_targets_argument_callback( return delete_targets -def add_bento_management_commands(cli: Group): +def bento_management_commands() -> click.Group: import bentoml from bentoml import Tag from bentoml._internal.bento.bento import DEFAULT_BENTO_BUILD_FILE @@ -72,11 +71,14 @@ def add_bento_management_commands(cli: Group): from bentoml._internal.utils import resolve_user_filepath from bentoml._internal.utils import rich_console as console from bentoml.bentos import import_bento + from bentoml_cli.utils import BentoMLCommandGroup - bento_store = BentoMLContainer.bento_store.get() - cloud_client = BentoMLContainer.bentocloud_client.get() + @click.group(cls=BentoMLCommandGroup) + def bentos(): + """Commands for managing Bento bundles.""" + pass - @cli.command() + @bentos.command() @click.argument("bento_tag", type=click.STRING) @click.option( "-o", @@ -84,7 +86,12 @@ def add_bento_management_commands(cli: Group): type=click.Choice(["json", "yaml", "path"]), default="yaml", ) - def get(bento_tag: str, output: str) -> None: # type: ignore (not accessed) + @inject + def get( + bento_tag: str, + output: str, + bento_store: BentoStore = Provide[BentoMLContainer.bento_store], + ) -> None: # type: ignore (not accessed) """Print Bento details by providing the bento_tag. \b @@ -102,7 +109,7 @@ def get(bento_tag: str, output: str) -> None: # type: ignore (not accessed) info = yaml.dump(bento.info, indent=2, sort_keys=False) console.print(Syntax(info, "yaml", background_color="default")) - @cli.command(name="list") + @bentos.command(name="list") @click.argument("bento_name", type=click.STRING, required=False) @click.option( "-o", @@ -110,7 +117,12 @@ def get(bento_tag: str, output: str) -> None: # type: ignore (not accessed) type=click.Choice(["json", "yaml", "table"]), default="table", ) - def list_bentos(bento_name: str, output: str) -> None: # type: ignore (not accessed) + @inject + def list_bentos( + bento_name: str, + output: str, + bento_store: BentoStore = Provide[BentoMLContainer.bento_store], + ) -> None: # type: ignore (not accessed) """List Bentos in local store \b @@ -141,7 +153,7 @@ def list_bentos(bento_name: str, output: str) -> None: # type: ignore (not acce info = json.dumps(res, indent=2) console.print(info) elif output == "yaml": - info = yaml.safe_dump(res, indent=2) + info = t.cast(str, yaml.safe_dump(res, indent=2)) console.print(Syntax(info, "yaml", background_color="default")) else: table = Table(box=None) @@ -158,7 +170,7 @@ def list_bentos(bento_name: str, output: str) -> None: # type: ignore (not acce ) console.print(table) - @cli.command() + @bentos.command() @click.argument( "delete_targets", nargs=-1, @@ -172,7 +184,12 @@ def list_bentos(bento_name: str, output: str) -> None: # type: ignore (not acce is_flag=True, help="Skip confirmation when deleting a specific bento bundle", ) - def delete(delete_targets: list[str], yes: bool) -> None: # type: ignore (not accessed) + @inject + def delete( + delete_targets: list[str], + yes: bool, + bento_store: BentoStore = Provide[BentoMLContainer.bento_store], + ) -> None: # type: ignore (not accessed) """Delete Bento in local bento store. \b @@ -205,7 +222,7 @@ def delete_target(target: str) -> None: for target in delete_targets: delete_target(target) - @cli.command() + @bentos.command() @click.argument("bento_tag", type=click.STRING) @click.argument( "out_path", @@ -213,7 +230,12 @@ def delete_target(target: str) -> None: default="", required=False, ) - def export(bento_tag: str, out_path: str) -> None: # type: ignore (not accessed) + @inject + def export( + bento_tag: str, + out_path: str, + bento_store: BentoStore = Provide[BentoMLContainer.bento_store], + ) -> None: # type: ignore (not accessed) """Export a Bento to an external file archive \b @@ -235,7 +257,7 @@ def export(bento_tag: str, out_path: str) -> None: # type: ignore (not accessed out_path = bento.export(out_path) click.echo(f"{bento} exported to {out_path}.") - @cli.command(name="import") + @bentos.command(name="import") @click.argument("bento_path", type=click.STRING) def import_bento_(bento_path: str) -> None: # type: ignore (not accessed) """Import a previously exported Bento archive file @@ -252,7 +274,7 @@ def import_bento_(bento_path: str) -> None: # type: ignore (not accessed) bento = import_bento(bento_path) click.echo(f"{bento} imported.") - @cli.command() + @bentos.command() @click.argument("bento_tag", type=click.STRING) @click.option( "-f", @@ -262,13 +284,19 @@ def import_bento_(bento_path: str) -> None: # type: ignore (not accessed) help="Force pull from remote Bento store to local and overwrite even if it already exists in local", ) @click.pass_obj - def pull(shared_options: SharedOptions, bento_tag: str, force: bool) -> None: # type: ignore (not accessed) + @inject + def pull( + shared_options: SharedOptions, + bento_tag: str, + force: bool, + cloud_client: BentoCloudClient = Provide[BentoMLContainer.bentocloud_client], + ) -> None: # type: ignore (not accessed) """Pull Bento from a remote Bento store server.""" cloud_client.pull_bento( bento_tag, force=force, context=shared_options.cloud_context ) - @cli.command() + @bentos.command() @click.argument("bento_tag", type=click.STRING) @click.option( "-f", @@ -284,8 +312,14 @@ def pull(shared_options: SharedOptions, bento_tag: str, force: bool) -> None: # help="Number of threads to use for upload", ) @click.pass_obj + @inject def push( - shared_options: SharedOptions, bento_tag: str, force: bool, threads: int + shared_options: SharedOptions, + bento_tag: str, + force: bool, + threads: int, + bento_store: BentoStore = Provide[BentoMLContainer.bento_store], + cloud_client: BentoCloudClient = Provide[BentoMLContainer.bentocloud_client], ) -> None: # type: ignore (not accessed) """Push Bento to a remote Bento store server.""" bento_obj = bento_store.get(bento_tag) @@ -298,7 +332,7 @@ def push( context=shared_options.cloud_context, ) - @cli.command() + @bentos.command() @click.argument("build_ctx", type=click.Path(), default=".") @click.option( "-f", @@ -429,3 +463,8 @@ def build( # type: ignore (not accessed) bentoml.container.build(bento.tag, backend=backend) return bento + + return bentos + + +bento_command = bento_management_commands() diff --git a/src/bentoml_cli/cli.py b/src/bentoml_cli/cli.py index 303af2493ad..b3894858541 100644 --- a/src/bentoml_cli/cli.py +++ b/src/bentoml_cli/cli.py @@ -5,19 +5,19 @@ import click import psutil -from bentoml_cli.bentos import add_bento_management_commands -from bentoml_cli.cloud import add_cloud_command -from bentoml_cli.containerize import add_containerize_command -from bentoml_cli.deployment import add_deployment_command -from bentoml_cli.env import add_env_command -from bentoml_cli.models import add_model_management_commands -from bentoml_cli.serve import add_serve_command -from bentoml_cli.start import add_start_command -from bentoml_cli.utils import BentoMLCommandGroup - - -def create_bentoml_cli() -> click.Group: + +def create_bentoml_cli() -> click.Command: from bentoml._internal.context import component_context + from bentoml_cli.bentos import bento_command + from bentoml_cli.cloud import cloud_command + from bentoml_cli.containerize import containerize_command + from bentoml_cli.deployment import deploy_command + from bentoml_cli.deployment import deployment_command + from bentoml_cli.env import env_command + from bentoml_cli.models import model_command + from bentoml_cli.serve import serve_command + from bentoml_cli.start import start_command + from bentoml_cli.utils import BentoMLCommandGroup component_context.component_type = "cli" @@ -37,14 +37,15 @@ def bentoml_cli(): """ # Add top-level CLI commands - add_env_command(bentoml_cli) - add_cloud_command(bentoml_cli) - add_bento_management_commands(bentoml_cli) - add_model_management_commands(bentoml_cli) - add_start_command(bentoml_cli) - add_serve_command(bentoml_cli) - add_containerize_command(bentoml_cli) - add_deployment_command(bentoml_cli) + bentoml_cli.add_command(env_command) + bentoml_cli.add_command(cloud_command) + bentoml_cli.add_command(model_command) + bentoml_cli.add_subcommands(bento_command) + bentoml_cli.add_subcommands(start_command) + bentoml_cli.add_subcommands(serve_command) + bentoml_cli.add_command(containerize_command) + bentoml_cli.add_command(deploy_command) + bentoml_cli.add_command(deployment_command) if psutil.WINDOWS: import sys diff --git a/src/bentoml_cli/cloud.py b/src/bentoml_cli/cloud.py index 97ba051256f..56f68479ea5 100644 --- a/src/bentoml_cli/cloud.py +++ b/src/bentoml_cli/cloud.py @@ -6,93 +6,92 @@ import click import click_option_group as cog +from bentoml._internal.cloud.client import RestApiClient +from bentoml._internal.cloud.config import CloudClientConfig +from bentoml._internal.cloud.config import CloudClientContext +from bentoml._internal.cloud.config import add_context +from bentoml._internal.cloud.config import default_context_name +from bentoml._internal.utils import bentoml_cattr +from bentoml.exceptions import CLIException +from bentoml_cli.utils import BentoMLCommandGroup + if t.TYPE_CHECKING: from .utils import SharedOptions -def add_cloud_command(cli: click.Group) -> click.Group: - from bentoml._internal.cloud.client import RestApiClient - from bentoml._internal.cloud.config import CloudClientConfig - from bentoml._internal.cloud.config import CloudClientContext - from bentoml._internal.cloud.config import add_context - from bentoml._internal.cloud.config import default_context_name - from bentoml._internal.utils import bentoml_cattr - from bentoml.exceptions import CLIException - from bentoml_cli.utils import BentoMLCommandGroup - - @cli.group(name="cloud", cls=BentoMLCommandGroup) - def cloud(): - """BentoCloud Subcommands Groups.""" - - @cloud.command() - @cog.optgroup.group( - "Login", help="Required login options", cls=cog.RequiredAllOptionGroup - ) - @cog.optgroup.option( - "--endpoint", - type=click.STRING, - help="BentoCloud or Yatai endpoint, i.e: https://cloud.bentoml.com", - ) - @cog.optgroup.option( - "--api-token", - type=click.STRING, - help="BentoCloud or Yatai user API token", +@click.group(name="cloud", cls=BentoMLCommandGroup) +def cloud_command(): + """BentoCloud Subcommands Groups.""" + + +@cloud_command.command() +@cog.optgroup.group( + "Login", help="Required login options", cls=cog.RequiredAllOptionGroup +) +@cog.optgroup.option( + "--endpoint", + type=click.STRING, + help="BentoCloud or Yatai endpoint, i.e: https://cloud.bentoml.com", +) +@cog.optgroup.option( + "--api-token", + type=click.STRING, + help="BentoCloud or Yatai user API token", +) +@click.pass_obj +def login(shared_options: SharedOptions, endpoint: str, api_token: str) -> None: # type: ignore (not accessed) + """Login to BentoCloud or Yatai server.""" + cloud_rest_client = RestApiClient(endpoint, api_token) + user = cloud_rest_client.v1.get_current_user() + + if user is None: + raise CLIException("current user is not found") + + org = cloud_rest_client.v1.get_current_organization() + + if org is None: + raise CLIException("current organization is not found") + + ctx = CloudClientContext( + name=shared_options.cloud_context + if shared_options.cloud_context is not None + else default_context_name, + endpoint=endpoint, + api_token=api_token, + email=user.email, ) - @click.pass_obj - def login(shared_options: SharedOptions, endpoint: str, api_token: str) -> None: # type: ignore (not accessed) - """Login to BentoCloud or Yatai server.""" - cloud_rest_client = RestApiClient(endpoint, api_token) - user = cloud_rest_client.v1.get_current_user() - if user is None: - raise CLIException("current user is not found") + add_context(ctx) + click.echo(f"Successfully logged in to Cloud for {user.name} in {org.name}") - org = cloud_rest_client.v1.get_current_organization() - if org is None: - raise CLIException("current organization is not found") - - ctx = CloudClientContext( - name=( - shared_options.cloud_context - if shared_options.cloud_context is not None - else default_context_name +@cloud_command.command() +def current_context() -> None: # type: ignore (not accessed) + """Get current cloud context.""" + click.echo( + json.dumps( + bentoml_cattr.unstructure( + CloudClientConfig.get_config().get_current_context() ), - endpoint=endpoint, - api_token=api_token, - email=user.email, + indent=2, ) + ) - add_context(ctx) - click.echo(f"Successfully logged in to Cloud for {user.name} in {org.name}") - - @cloud.command() - def current_context() -> None: # type: ignore (not accessed) - """Get current cloud context.""" - click.echo( - json.dumps( - bentoml_cattr.unstructure( - CloudClientConfig.get_config().get_current_context() - ), - indent=2, - ) - ) - @cloud.command() - def list_context() -> None: # type: ignore (not accessed) - """List all available context.""" - config = CloudClientConfig.get_config() - click.echo( - json.dumps( - bentoml_cattr.unstructure([i.name for i in config.contexts]), indent=2 - ) +@cloud_command.command() +def list_context() -> None: # type: ignore (not accessed) + """List all available context.""" + config = CloudClientConfig.get_config() + click.echo( + json.dumps( + bentoml_cattr.unstructure([i.name for i in config.contexts]), indent=2 ) + ) - @cloud.command() - @click.argument("context_name", type=click.STRING) - def update_current_context(context_name: str) -> None: # type: ignore (not accessed) - """Update current context""" - ctx = CloudClientConfig.get_config().set_current_context(context_name) - click.echo(f"Successfully switched to context: {ctx.name}") - return cli +@cloud_command.command() +@click.argument("context_name", type=click.STRING) +def update_current_context(context_name: str) -> None: # type: ignore (not accessed) + """Update current context""" + ctx = CloudClientConfig.get_config().set_current_context(context_name) + click.echo(f"Successfully switched to context: {ctx.name}") diff --git a/src/bentoml_cli/containerize.py b/src/bentoml_cli/containerize.py index 72629372c17..a04c9b0b8ff 100644 --- a/src/bentoml_cli/containerize.py +++ b/src/bentoml_cli/containerize.py @@ -9,10 +9,24 @@ import typing as t from functools import partial +import click +from click_option_group import optgroup + +from bentoml import container +from bentoml._internal.configuration import get_debug_mode +from bentoml._internal.container import FEATURES +from bentoml._internal.container import REGISTERED_BACKENDS +from bentoml._internal.container import determine_container_tag +from bentoml._internal.container import enable_buildkit +from bentoml.exceptions import BentoMLException +from bentoml_cli.utils import kwargs_transformers +from bentoml_cli.utils import normalize_none_type +from bentoml_cli.utils import opt_callback +from bentoml_cli.utils import validate_container_tag + if t.TYPE_CHECKING: from click import Command from click import Context - from click import Group from click import Parameter from bentoml._internal.container import DefaultBuilder @@ -368,252 +382,232 @@ def buildx_options_group(f: F[t.Any]): return f -def add_containerize_command(cli: Group) -> None: - import click - from click_option_group import optgroup +@click.command(name="containerize") +@click.argument("bento_tag", type=click.STRING, metavar="BENTO:TAG") +@click.option( + "-t", + "--image-tag", + "--docker-image-tag", + metavar="NAME:TAG", + help="Name and optionally a tag (format: ``name:tag``) for building container, defaults to the bento tag.", + required=False, + callback=validate_container_tag, + multiple=True, +) +@click.option( + "--backend", + help="Define builder backend. Available: {}".format( + ", ".join(map(lambda s: f"``{s}``", REGISTERED_BACKENDS)) + ), + required=False, + default="docker", + envvar="BENTOML_CONTAINERIZE_BACKEND", + type=click.STRING, +) +@click.option( + "--enable-features", + multiple=True, + nargs=1, + metavar="FEATURE[,FEATURE]", + help="Enable additional BentoML features. Available features are: {}.".format( + ", ".join(map(lambda s: f"``{s}``", FEATURES)) + ), +) +@click.option( + "--opt", + help="Define options for given backend. (format: ``--opt target=foo --opt build-arg=foo=BAR --opt iidfile:/path/to/file --opt no-cache``)", + required=False, + multiple=True, + callback=opt_callback, + metavar="ARG=VALUE[,ARG=VALUE]", +) +@click.option( + "--run-as-root", + help="Whether to run backend with as root.", + is_flag=True, + required=False, + default=False, + type=bool, +) +@optgroup.group( + "Equivalent options", help="Equivalent option group for backward compatibility" +) +@compatible_options_group +@optgroup.group("Buildx options", help="[Only available with '--backend=buildx']") +@buildx_options_group +@kwargs_transformers(transformer=normalize_none_type) +def containerize_command( # type: ignore + bento_tag: str, + image_tag: tuple[str] | None, + backend: DefaultBuilder, + enable_features: tuple[str] | None, + run_as_root: bool, + _memoized: dict[str, t.Any], + **kwargs: t.Any, # pylint: disable=unused-argument +) -> None: + """Containerizes given Bento into an OCI-compliant container, with any given OCI builder. + + \b + + ``BENTO`` is the target BentoService to be containerized, referenced by its name + and version in format of name:version. For example: ``iris_classifier:v1.2.0`` + + \b + ``bentoml containerize`` command also supports the use of the ``latest`` tag + which will automatically use the last built version of your Bento. + + \b + You can provide a tag for the image built by Bento using the + ``--image-tag`` flag. + + \b + You can also prefixing the tag with a hostname for the repository you wish to push to. + + .. code-block:: bash + + $ bentoml containerize iris_classifier:latest -t repo-address.com:username/iris + + This would build an image called ``username/iris:latest`` and push it to docker repository at ``repo-address.com``. + + \b + By default, the ``containerize`` command will use the current credentials provided by each backend implementation. + + \b + ``bentoml containerize`` also leverage BuildKit features such as multi-node + builds for cross-platform images, more efficient caching, and more secure builds. + BuildKit will be enabled by default by each backend implementation. To opt-out of BuildKit, set ``DOCKER_BUILDKIT=0``: + + .. code-block:: bash + + $ DOCKER_BUILDKIT=0 bentoml containerize iris_classifier:latest + + \b + ``bentoml containerize`` is backend-agnostic, meaning that it can use any OCI-compliant builder to build the container image. + By default, it will use the ``docker`` backend, which will use the local Docker daemon to build the image. + You can use the ``--backend`` flag to specify a different backend. Note that backend flags will now be unified via ``--opt``. + + ``--opt`` accepts multiple flags, and it works with all backend options. + + \b + To pass a boolean flag (e.g: ``--no-cache``), use ``--opt no-cache``. + + \b + To pass a key-value pair (e.g: ``--build-arg FOO=BAR``), use ``--opt build-arg=FOO=BAR`` or ``--opt build-arg:FOO=BAR``. Make sure to quote the value if it contains spaces. + + \b + To pass a argument with one value (path, integer, etc) (e.g: ``--cgroup-parent cgroupv2``), use ``--opt cgroup-parent=cgroupv2`` or ``--opt cgroup-parent:cgroupv2``. + """ - from bentoml import container - from bentoml._internal.configuration import get_debug_mode - from bentoml._internal.container import FEATURES - from bentoml._internal.container import REGISTERED_BACKENDS - from bentoml._internal.container import determine_container_tag - from bentoml._internal.container import enable_buildkit - from bentoml.exceptions import BentoMLException - from bentoml_cli.utils import kwargs_transformers - from bentoml_cli.utils import normalize_none_type - from bentoml_cli.utils import opt_callback - from bentoml_cli.utils import validate_container_tag - - @cli.command() - @click.argument("bento_tag", type=click.STRING, metavar="BENTO:TAG") - @click.option( - "-t", - "--image-tag", - "--docker-image-tag", - metavar="NAME:TAG", - help="Name and optionally a tag (format: ``name:tag``) for building container, defaults to the bento tag.", - required=False, - callback=validate_container_tag, - multiple=True, - ) - @click.option( - "--backend", - help="Define builder backend. Available: {}".format( - ", ".join(map(lambda s: f"``{s}``", REGISTERED_BACKENDS)) - ), - required=False, - default="docker", - envvar="BENTOML_CONTAINERIZE_BACKEND", - type=click.STRING, - ) - @click.option( - "--enable-features", - multiple=True, - nargs=1, - metavar="FEATURE[,FEATURE]", - help="Enable additional BentoML features. Available features are: {}.".format( - ", ".join(map(lambda s: f"``{s}``", FEATURES)) - ), - ) - @click.option( - "--opt", - help="Define options for given backend. (format: ``--opt target=foo --opt build-arg=foo=BAR --opt iidfile:/path/to/file --opt no-cache``)", - required=False, - multiple=True, - callback=opt_callback, - metavar="ARG=VALUE[,ARG=VALUE]", - ) - @click.option( - "--run-as-root", - help="Whether to run backend with as root.", - is_flag=True, - required=False, - default=False, - type=bool, - ) - @optgroup.group( - "Equivalent options", help="Equivalent option group for backward compatibility" + # we can filter out all options that are None or False. + _memoized = {k: v for k, v in _memoized.items() if v} + logger.debug("Memoized: %s", _memoized) + + # Run healthcheck before containerizing + # build will also run healthcheck, but we want to fail early. + try: + container.health(backend) + except subprocess.CalledProcessError: + raise BentoMLException(f"Backend {backend} is not healthy") + + # --progress is not available without BuildKit. + if not enable_buildkit(backend=backend) and "progress" in _memoized: + _memoized.pop("progress") + elif get_debug_mode(): + _memoized["progress"] = "plain" + _memoized["_run_as_root"] = run_as_root + + features: tuple[str] | None = None + if enable_features: + features = tuple( + itertools.chain.from_iterable(map(lambda s: s.split(","), enable_features)) + ) + result = container.build( + bento_tag, + backend=backend, + image_tag=image_tag, + features=features, + **_memoized, ) - @compatible_options_group - @optgroup.group("Buildx options", help="[Only available with '--backend=buildx']") - @buildx_options_group - @kwargs_transformers(transformer=normalize_none_type) - def containerize( # type: ignore - bento_tag: str, - image_tag: tuple[str] | None, - backend: DefaultBuilder, - enable_features: tuple[str] | None, - run_as_root: bool, - _memoized: dict[str, t.Any], - **kwargs: t.Any, # pylint: disable=unused-argument - ) -> None: - """Containerizes given Bento into an OCI-compliant container, with any given OCI builder. - - \b - - ``BENTO`` is the target BentoService to be containerized, referenced by its name - and version in format of name:version. For example: ``iris_classifier:v1.2.0`` - - \b - ``bentoml containerize`` command also supports the use of the ``latest`` tag - which will automatically use the last built version of your Bento. - - \b - You can provide a tag for the image built by Bento using the - ``--image-tag`` flag. - - \b - You can also prefixing the tag with a hostname for the repository you wish to push to. - - .. code-block:: bash - - $ bentoml containerize iris_classifier:latest -t repo-address.com:username/iris - - This would build an image called ``username/iris:latest`` and push it to docker repository at ``repo-address.com``. - - \b - By default, the ``containerize`` command will use the current credentials provided by each backend implementation. - - \b - ``bentoml containerize`` also leverage BuildKit features such as multi-node - builds for cross-platform images, more efficient caching, and more secure builds. - BuildKit will be enabled by default by each backend implementation. To opt-out of BuildKit, set ``DOCKER_BUILDKIT=0``: - - .. code-block:: bash - - $ DOCKER_BUILDKIT=0 bentoml containerize iris_classifier:latest - - \b - ``bentoml containerize`` is backend-agnostic, meaning that it can use any OCI-compliant builder to build the container image. - By default, it will use the ``docker`` backend, which will use the local Docker daemon to build the image. - You can use the ``--backend`` flag to specify a different backend. Note that backend flags will now be unified via ``--opt``. - - ``--opt`` accepts multiple flags, and it works with all backend options. - - \b - To pass a boolean flag (e.g: ``--no-cache``), use ``--opt no-cache``. - - \b - To pass a key-value pair (e.g: ``--build-arg FOO=BAR``), use ``--opt build-arg=FOO=BAR`` or ``--opt build-arg:FOO=BAR``. Make sure to quote the value if it contains spaces. - - \b - To pass a argument with one value (path, integer, etc) (e.g: ``--cgroup-parent cgroupv2``), use ``--opt cgroup-parent=cgroupv2`` or ``--opt cgroup-parent:cgroupv2``. - """ - - # we can filter out all options that are None or False. - _memoized = {k: v for k, v in _memoized.items() if v} - logger.debug("Memoized: %s", _memoized) - - # Run healthcheck before containerizing - # build will also run healthcheck, but we want to fail early. - try: - container.health(backend) - except subprocess.CalledProcessError: - raise BentoMLException(f"Backend {backend} is not healthy") - - # --progress is not available without BuildKit. - if not enable_buildkit(backend=backend) and "progress" in _memoized: - _memoized.pop("progress") - elif get_debug_mode(): - _memoized["progress"] = "plain" - _memoized["_run_as_root"] = run_as_root - - features: tuple[str] | None = None - if enable_features: - features = tuple( - itertools.chain.from_iterable( - map(lambda s: s.split(","), enable_features) + + if result is not None: + tags = determine_container_tag(bento_tag, image_tag=image_tag) + + container_runtime = backend + # This logic determines some of the output messages. + if backend == "buildah": + if shutil.which("podman") is None: + logger.warning( + "'buildah' are only used to build the image. To run the image, 'podman' is required to be installed (podman not found under PATH). See https://podman.io/getting-started/installation." ) - ) - result = container.build( - bento_tag, - backend=backend, - image_tag=image_tag, - features=features, - **_memoized, + sys.exit(0) + else: + container_runtime = "podman" + elif backend == "buildx": + # buildx is a syntatic sugar for docker buildx build + container_runtime = "docker" + elif backend == "buildctl": + if shutil.which("docker") is not None: + container_runtime = "docker" + elif shutil.which("podman") is not None: + container_runtime = "podman" + elif shutil.which("nerdctl") is not None: + container_runtime = "nerdctl" + else: + click.echo( + click.style( + "To load image built with 'buildctl' requires one of " + "docker, podman, nerdctl (None are found in PATH). " + "Make sure they are visible to PATH and try again.", + fg="yellow", + ), + color=True, + ) + sys.exit(0) + if "output" not in _memoized: + tmp_path = tempfile.gettempdir() + type_prefix = "type=oci,name=" + if container_runtime == "docker": + type_prefix = "type=docker,name=docker.io/" + click.echo( + click.style( + "Autoconfig is now deprecated and will be removed " + "in the next future release. We recommend setting " + "output to a tarfile should you wish to load the " + "image locally. For example:", + fg="yellow", + ), + color=True, + ) + click.echo( + click.style( + f" bentoml containerize {bento_tag} --backend buildctl --opt output={type_prefix}{tags[0]},dest={tmp_path}/{tags[0].replace(':', '_')}.tar\n" + f" {container_runtime} load -i {tmp_path}/{tags[0].replace(':', '_')}.tar", + fg="yellow", + ), + color=True, + ) + o = subprocess.check_output([container_runtime, "load"], input=result) + if get_debug_mode(): + click.echo(o.decode("utf-8").strip()) + sys.exit(0) + return result + + click.echo( + f'Successfully built Bento container for "{bento_tag}" with tag(s) "{",".join(tags)}"', + ) + instructions = ( + "To run your newly built Bento container, run:\n" + + f" {container_runtime} run --rm -p 3000:3000 {tags[0]}\n" ) - if result is not None: - tags = determine_container_tag(bento_tag, image_tag=image_tag) - - container_runtime = backend - # This logic determines some of the output messages. - if backend == "buildah": - if shutil.which("podman") is None: - logger.warning( - "'buildah' are only used to build the image. To run the image, 'podman' is required to be installed (podman not found under PATH). See https://podman.io/getting-started/installation." - ) - sys.exit(0) - else: - container_runtime = "podman" - elif backend == "buildx": - # buildx is a syntatic sugar for docker buildx build - container_runtime = "docker" - elif backend == "buildctl": - if shutil.which("docker") is not None: - container_runtime = "docker" - elif shutil.which("podman") is not None: - container_runtime = "podman" - elif shutil.which("nerdctl") is not None: - container_runtime = "nerdctl" - else: - click.echo( - click.style( - "To load image built with 'buildctl' requires one of " - "docker, podman, nerdctl (None are found in PATH). " - "Make sure they are visible to PATH and try again.", - fg="yellow", - ), - color=True, - ) - sys.exit(0) - if "output" not in _memoized: - tmp_path = tempfile.gettempdir() - type_prefix = "type=oci,name=" - if container_runtime == "docker": - type_prefix = "type=docker,name=docker.io/" - click.echo( - click.style( - "Autoconfig is now deprecated and will be removed " - "in the next future release. We recommend setting " - "output to a tarfile should you wish to load the " - "image locally. For example:", - fg="yellow", - ), - color=True, - ) - click.echo( - click.style( - f" bentoml containerize {bento_tag} --backend buildctl --opt output={type_prefix}{tags[0]},dest={tmp_path}/{tags[0].replace(':', '_')}.tar\n" - f" {container_runtime} load -i {tmp_path}/{tags[0].replace(':', '_')}.tar", - fg="yellow", - ), - color=True, - ) - o = subprocess.check_output( - [container_runtime, "load"], input=result - ) - if get_debug_mode(): - click.echo(o.decode("utf-8").strip()) - sys.exit(0) - return result - - click.echo( - f'Successfully built Bento container for "{bento_tag}" with tag(s) "{",".join(tags)}"', + if features is not None and any( + has_grpc in features + for has_grpc in ("all", "grpc", "grpc-reflection", "grpc-channelz") + ): + instructions += ( + "To serve with gRPC instead, run:\n" + + f" {container_runtime} run --rm -p 3000:3000 -p 3001:3001 {tags[0]} serve-grpc\n" ) - instructions = ( - "To run your newly built Bento container, run:\n" - + f" {container_runtime} run --rm -p 3000:3000 {tags[0]}\n" - ) - - if features is not None and any( - has_grpc in features - for has_grpc in ("all", "grpc", "grpc-reflection", "grpc-channelz") - ): - instructions += ( - "To serve with gRPC instead, run:\n" - + f" {container_runtime} run --rm -p 3000:3000 -p 3001:3001 {tags[0]} serve-grpc\n" - ) - click.echo(instructions, nl=False) - raise SystemExit(0) - raise SystemExit(1) + click.echo(instructions, nl=False) + raise SystemExit(0) + raise SystemExit(1) diff --git a/src/bentoml_cli/deployment.py b/src/bentoml_cli/deployment.py index 0ec0bf86637..5ac7e094147 100644 --- a/src/bentoml_cli/deployment.py +++ b/src/bentoml_cli/deployment.py @@ -7,12 +7,15 @@ import yaml from rich.live import Live from rich.syntax import Syntax +from rich.table import Table from bentoml._internal.cloud.base import Spinner from bentoml._internal.cloud.deployment import Deployment from bentoml._internal.cloud.deployment import DeploymentConfigParameters from bentoml._internal.cloud.schemas.modelschemas import DeploymentStrategy +from bentoml._internal.utils import rich_console as console from bentoml.exceptions import BentoMLException +from bentoml_cli.utils import BentoMLCommandGroup if t.TYPE_CHECKING: TupleStrAny = tuple[str, ...] @@ -22,672 +25,661 @@ TupleStrAny = tuple -def add_deployment_command(cli: click.Group) -> None: - from rich.table import Table - - from bentoml._internal.utils import rich_console as console - from bentoml_cli.utils import BentoMLCommandGroup +@click.command(name="deploy") +@click.argument( + "bento", + type=click.STRING, + required=False, +) +@click.option( + "-n", + "--name", + type=click.STRING, + help="Deployment name", +) +@click.option( + "--cluster", + type=click.STRING, + help="Name of the cluster", +) +@click.option( + "--access-authorization", + type=click.BOOL, + help="Enable access authorization", +) +@click.option( + "--scaling-min", + type=click.INT, + help="Minimum scaling value", +) +@click.option( + "--scaling-max", + type=click.INT, + help="Maximum scaling value", +) +@click.option( + "--instance-type", + type=click.STRING, + help="Type of instance", +) +@click.option( + "--strategy", + type=click.Choice( + [deployment_strategy.value for deployment_strategy in DeploymentStrategy] + ), + help="Deployment strategy", +) +@click.option( + "--env", + type=click.STRING, + help="List of environment variables pass by --env key=value, --env ...", + multiple=True, +) +@click.option( + "-f", + "--config-file", + type=click.File(), + help="Configuration file path", + default=None, +) +@click.option( + "--config-dict", + type=click.STRING, + help="Configuration json string", + default=None, +) +@click.option( + "--wait/--no-wait", + type=click.BOOL, + is_flag=True, + help="Do not wait for deployment to be ready", + default=True, +) +@click.option( + "--timeout", + type=click.INT, + default=1800, + help="Timeout for deployment to be ready in seconds", +) +@click.pass_obj +def deploy_command( + shared_options: SharedOptions, + bento: str | None, + name: str | None, + cluster: str | None, + access_authorization: bool | None, + scaling_min: int | None, + scaling_max: int | None, + instance_type: str | None, + strategy: str | None, + env: tuple[str] | None, + config_file: str | t.TextIO | None, + config_dict: str | None, + wait: bool, + timeout: int, +) -> None: + """Create a deployment on BentoCloud. - @cli.command() - @click.argument( - "bento", - type=click.STRING, - required=False, - ) - @click.option( - "-n", - "--name", - type=click.STRING, - help="Deployment name", - ) - @click.option( - "--cluster", - type=click.STRING, - help="Name of the cluster", - ) - @click.option( - "--access-authorization", - type=click.BOOL, - help="Enable access authorization", - ) - @click.option( - "--scaling-min", - type=click.INT, - help="Minimum scaling value", - ) - @click.option( - "--scaling-max", - type=click.INT, - help="Maximum scaling value", - ) - @click.option( - "--instance-type", - type=click.STRING, - help="Type of instance", - ) - @click.option( - "--strategy", - type=click.Choice( - [deployment_strategy.value for deployment_strategy in DeploymentStrategy] - ), - help="Deployment strategy", - ) - @click.option( - "--env", - type=click.STRING, - help="List of environment variables pass by --env key=value, --env ...", - multiple=True, - ) - @click.option( - "-f", - "--config-file", - type=click.File(), - help="Configuration file path", - default=None, - ) - @click.option( - "--config-dict", - type=click.STRING, - help="Configuration json string", - default=None, - ) - @click.option( - "--wait/--no-wait", - type=click.BOOL, - is_flag=True, - help="Do not wait for deployment to be ready", - default=True, - ) - @click.option( - "--timeout", - type=click.INT, - default=1800, - help="Timeout for deployment to be ready in seconds", + \b + Create a deployment using parameters, or using config yaml file. + """ + create_deployment( + context=shared_options.cloud_context, + bento=bento, + name=name, + cluster=cluster, + access_authorization=access_authorization, + scaling_min=scaling_min, + scaling_max=scaling_max, + instance_type=instance_type, + strategy=strategy, + env=env, + config_file=config_file, + config_dict=config_dict, + wait=wait, + timeout=timeout, ) - @click.pass_obj - def deploy( - shared_options: SharedOptions, - bento: str | None, - name: str | None, - cluster: str | None, - access_authorization: bool | None, - scaling_min: int | None, - scaling_max: int | None, - instance_type: str | None, - strategy: str | None, - env: tuple[str] | None, - config_file: str | t.TextIO | None, - config_dict: str | None, - wait: bool, - timeout: int, - ) -> None: - """Create a deployment on BentoCloud. - - \b - Create a deployment using parameters, or using config yaml file. - """ - create_deployment( - context=shared_options.cloud_context, - bento=bento, - name=name, - cluster=cluster, - access_authorization=access_authorization, - scaling_min=scaling_min, - scaling_max=scaling_max, - instance_type=instance_type, - strategy=strategy, - env=env, - config_file=config_file, - config_dict=config_dict, - wait=wait, - timeout=timeout, - ) - output_option = click.option( - "-o", - "--output", - type=click.Choice(["yaml", "json"]), - default="yaml", - help="Display the output of this command.", - ) - def shared_decorator( - f: t.Callable[..., t.Any] | None = None, - ) -> t.Callable[..., t.Any]: - def decorate(f: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: - options = [ - click.option( - "--cluster", - type=click.STRING, - default=None, - help="Name of the cluster.", - ), - ] - for opt in reversed(options): - f = opt(f) - return f - - if f: - return decorate(f) - else: - return decorate - - @cli.group(name="deployment", cls=BentoMLCommandGroup) - def deployment_cli(): - """Deployment Subcommands Groups""" - - @deployment_cli.command() - @shared_decorator() - @click.argument( - "name", - type=click.STRING, - required=False, - ) - @click.option( - "--bento", - type=click.STRING, - help="Bento name or path to Bento project directory", - ) - @click.option( - "--access-authorization", - type=click.BOOL, - help="Enable access authorization", - ) - @click.option( - "--scaling-min", - type=click.INT, - help="Minimum scaling value", - ) - @click.option( - "--scaling-max", - type=click.INT, - help="Maximum scaling value", - ) - @click.option( - "--instance-type", - type=click.STRING, - help="Type of instance", - ) - @click.option( - "--strategy", - type=click.Choice( - [deployment_strategy.value for deployment_strategy in DeploymentStrategy] - ), - help="Deployment strategy", - ) - @click.option( - "--env", - type=click.STRING, - help="List of environment variables pass by --env key=value, --env ...", - multiple=True, - ) - @click.option( - "-f", - "--config-file", - type=click.File(), - help="Configuration file path, mututally exclusive with other config options", - default=None, - ) - @click.option( - "--config-dict", - type=click.STRING, - help="Configuration json string", - default=None, - ) - @click.pass_obj - def update( # type: ignore - shared_options: SharedOptions, - name: str | None, - cluster: str | None, - bento: str | None, - access_authorization: bool | None, - scaling_min: int | None, - scaling_max: int | None, - instance_type: str | None, - strategy: str | None, - env: tuple[str] | None, - config_file: t.TextIO | None, - config_dict: str | None, - ) -> None: - """Update a deployment on BentoCloud. - - \b - A deployment can be updated using parameters, or using config yaml file. - You can also update bento by providing a project path or existing bento. - """ - cfg_dict = None - if config_dict is not None and config_dict != "": - cfg_dict = json.loads(config_dict) - config_params = DeploymentConfigParameters( - name=name, - context=shared_options.cloud_context, - bento=bento, - cluster=cluster, - access_authorization=access_authorization, - scaling_max=scaling_max, - scaling_min=scaling_min, - instance_type=instance_type, - strategy=strategy, - envs=( - [ - {"name": item.split("=")[0], "value": item.split("=")[1]} - for item in env - ] - if env is not None - else None - ), - config_file=config_file, - config_dict=cfg_dict, - ) - try: - config_params.verify() - except BentoMLException as e: - raise BentoMLException( - f"Failed to create deployment due to invalid configuration: {e}" - ) - deployment_info = Deployment.update( - deployment_config_params=config_params, - context=shared_options.cloud_context, - ) +output_option = click.option( + "-o", + "--output", + type=click.Choice(["yaml", "json"]), + default="yaml", + help="Display the output of this command.", +) - click.echo(f"Deployment '{deployment_info.name}' updated successfully.") - @deployment_cli.command() - @click.argument( - "bento", - type=click.STRING, - required=False, - ) - @click.option( - "-n", - "--name", - type=click.STRING, - help="Deployment name", - ) - @click.option( - "--cluster", - type=click.STRING, - help="Name of the cluster", - ) - @click.option( - "--access-authorization", - type=click.BOOL, - help="Enable access authorization", - ) - @click.option( - "--scaling-min", - type=click.INT, - help="Minimum scaling value", - ) - @click.option( - "--scaling-max", - type=click.INT, - help="Maximum scaling value", - ) - @click.option( - "--instance-type", - type=click.STRING, - help="Type of instance", - ) - @click.option( - "--strategy", - type=click.Choice( - [deployment_strategy.value for deployment_strategy in DeploymentStrategy] - ), - help="Deployment strategy", - ) - @click.option( - "--env", - type=click.STRING, - help="List of environment variables pass by --env key=value, --env ...", - multiple=True, - ) - @click.option( - "-f", - "--config-file", - type=click.File(), - help="Configuration file path", - default=None, - ) - @click.option( - "-f", - "--config-file", - help="Configuration file path, mututally exclusive with other config options", - default=None, - ) - @click.option( - "--config-dict", - type=click.STRING, - help="Configuration json string", - default=None, - ) - @click.pass_obj - def apply( # type: ignore - shared_options: SharedOptions, - bento: str | None, - name: str | None, - cluster: str | None, - access_authorization: bool | None, - scaling_min: int | None, - scaling_max: int | None, - instance_type: str | None, - strategy: str | None, - env: tuple[str] | None, - config_file: str | t.TextIO | None, - config_dict: str | None, - ) -> None: - """Apply a deployment on BentoCloud. - - \b - A deployment can be applied using config yaml file. - """ - cfg_dict = None - if config_dict is not None and config_dict != "": - cfg_dict = json.loads(config_dict) - config_params = DeploymentConfigParameters( - name=name, - context=shared_options.cloud_context, - bento=bento, - cluster=cluster, - access_authorization=access_authorization, - scaling_max=scaling_max, - scaling_min=scaling_min, - instance_type=instance_type, - strategy=strategy, - envs=( - [ - {"key": item.split("=")[0], "value": item.split("=")[1]} - for item in env - ] - if env is not None - else None +def shared_decorator( + f: t.Callable[..., t.Any] | None = None, +) -> t.Callable[..., t.Any]: + def decorate(f: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: + options = [ + click.option( + "--cluster", + type=click.STRING, + default=None, + help="Name of the cluster.", ), - config_file=config_file, - config_dict=cfg_dict, - ) - try: - config_params.verify() - except BentoMLException as e: - raise BentoMLException( - f"Failed to create deployment due to invalid configuration: {e}" - ) - deployment_info = Deployment.apply( - deployment_config_params=config_params, - context=shared_options.cloud_context, - ) + ] + for opt in reversed(options): + f = opt(f) + return f - click.echo(f"Deployment '{deployment_info.name}' applied successfully.") + if f: + return decorate(f) + else: + return decorate - @deployment_cli.command() - @click.argument( - "bento", - type=click.STRING, - required=False, - ) - @click.option( - "-n", - "--name", - type=click.STRING, - help="Deployment name", - ) - @click.option( - "--cluster", - type=click.STRING, - help="Name of the cluster", - ) - @click.option( - "--access-authorization", - type=click.BOOL, - help="Enable access authorization", - ) - @click.option( - "--scaling-min", - type=click.INT, - help="Minimum scaling value", - ) - @click.option( - "--scaling-max", - type=click.INT, - help="Maximum scaling value", - ) - @click.option( - "--instance-type", - type=click.STRING, - help="Type of instance", - ) - @click.option( - "--strategy", - type=click.Choice( - [deployment_strategy.value for deployment_strategy in DeploymentStrategy] - ), - help="Deployment strategy", - ) - @click.option( - "--env", - type=click.STRING, - help="List of environment variables pass by --env key=value, --env ...", - multiple=True, - ) - @click.option( - "-f", - "--config-file", - type=click.File(), - help="Configuration file path", - default=None, - ) - @click.option( - "--config-dict", - type=click.STRING, - help="Configuration json string", - default=None, - ) - @click.option( - "--wait/--no-wait", - type=click.BOOL, - is_flag=True, - help="Do not wait for deployment to be ready", - default=True, - ) - @click.option( - "--timeout", - type=click.INT, - default=1800, - help="Timeout for deployment to be ready in seconds", - ) - @click.pass_obj - def create( - shared_options: SharedOptions, - bento: str | None, - name: str | None, - cluster: str | None, - access_authorization: bool | None, - scaling_min: int | None, - scaling_max: int | None, - instance_type: str | None, - strategy: str | None, - env: tuple[str] | None, - config_file: str | t.TextIO | None, - config_dict: str | None, - wait: bool, - timeout: int, - ) -> None: - """Create a deployment on BentoCloud. - - \b - Create a deployment using parameters, or using config yaml file. - """ - create_deployment( - context=shared_options.cloud_context, - bento=bento, - name=name, - cluster=cluster, - access_authorization=access_authorization, - scaling_min=scaling_min, - scaling_max=scaling_max, - instance_type=instance_type, - strategy=strategy, - env=env, - config_file=config_file, - config_dict=config_dict, - wait=wait, - timeout=timeout, - ) - @deployment_cli.command() - @shared_decorator - @click.argument( - "name", - type=click.STRING, - required=True, - ) - @output_option - @click.pass_obj - def get( # type: ignore - shared_options: SharedOptions, - name: str, - cluster: str | None, - output: t.Literal["json", "default"], - ) -> None: - """Get a deployment on BentoCloud.""" - d = Deployment.get(name, context=shared_options.cloud_context, cluster=cluster) - if output == "json": - info = json.dumps(d.to_dict(), indent=2, default=str) - console.print_json(info) - else: - info = yaml.dump(d.to_dict(), indent=2, sort_keys=False) - console.print(Syntax(info, "yaml", background_color="default")) - - @deployment_cli.command() - @shared_decorator - @click.argument( - "name", - type=click.STRING, - required=True, - ) - @click.pass_obj - def terminate( # type: ignore - shared_options: SharedOptions, - name: str, - cluster: str | None, - ) -> None: - """Terminate a deployment on BentoCloud.""" - Deployment.terminate( - name, context=shared_options.cloud_context, cluster=cluster - ) - click.echo(f"Deployment '{name}' terminated successfully.") +@click.group(name="deployment", cls=BentoMLCommandGroup) +def deployment_command(): + """Deployment Subcommands Groups""" - @deployment_cli.command() - @click.argument( - "name", - type=click.STRING, - required=True, - ) - @shared_decorator - @click.pass_obj - def delete( # type: ignore - shared_options: SharedOptions, - name: str, - cluster: str | None, - ) -> None: - """Delete a deployment on BentoCloud.""" - Deployment.delete(name, context=shared_options.cloud_context, cluster=cluster) - click.echo(f"Deployment '{name}' deleted successfully.") - - @deployment_cli.command() - @click.option( - "--cluster", type=click.STRING, default=None, help="Name of the cluster." + +@deployment_command.command() +@shared_decorator() +@click.argument( + "name", + type=click.STRING, + required=False, +) +@click.option( + "--bento", + type=click.STRING, + help="Bento name or path to Bento project directory", +) +@click.option( + "--access-authorization", + type=click.BOOL, + help="Enable access authorization", +) +@click.option( + "--scaling-min", + type=click.INT, + help="Minimum scaling value", +) +@click.option( + "--scaling-max", + type=click.INT, + help="Maximum scaling value", +) +@click.option( + "--instance-type", + type=click.STRING, + help="Type of instance", +) +@click.option( + "--strategy", + type=click.Choice( + [deployment_strategy.value for deployment_strategy in DeploymentStrategy] + ), + help="Deployment strategy", +) +@click.option( + "--env", + type=click.STRING, + help="List of environment variables pass by --env key=value, --env ...", + multiple=True, +) +@click.option( + "-f", + "--config-file", + type=click.File(), + help="Configuration file path, mututally exclusive with other config options", + default=None, +) +@click.option( + "--config-dict", + type=click.STRING, + help="Configuration json string", + default=None, +) +@click.pass_obj +def update( # type: ignore + shared_options: SharedOptions, + name: str | None, + cluster: str | None, + bento: str | None, + access_authorization: bool | None, + scaling_min: int | None, + scaling_max: int | None, + instance_type: str | None, + strategy: str | None, + env: tuple[str] | None, + config_file: t.TextIO | None, + config_dict: str | None, +) -> None: + """Update a deployment on BentoCloud. + + \b + A deployment can be updated using parameters, or using config yaml file. + You can also update bento by providing a project path or existing bento. + """ + cfg_dict = None + if config_dict is not None and config_dict != "": + cfg_dict = json.loads(config_dict) + config_params = DeploymentConfigParameters( + name=name, + context=shared_options.cloud_context, + bento=bento, + cluster=cluster, + access_authorization=access_authorization, + scaling_max=scaling_max, + scaling_min=scaling_min, + instance_type=instance_type, + strategy=strategy, + envs=[{"name": item.split("=")[0], "value": item.split("=")[1]} for item in env] + if env is not None + else None, + config_file=config_file, + config_dict=cfg_dict, ) - @click.option( - "--search", type=click.STRING, default=None, help="Search for list request." + try: + config_params.verify() + except BentoMLException as e: + raise BentoMLException( + f"Failed to create deployment due to invalid configuration: {e}" + ) + deployment_info = Deployment.update( + deployment_config_params=config_params, + context=shared_options.cloud_context, ) - @click.option( - "-o", - "--output", - help="Display the output of this command.", - type=click.Choice(["json", "yaml", "table"]), - default="table", + + click.echo(f"Deployment '{deployment_info.name}' updated successfully.") + + +@deployment_command.command() +@click.argument( + "bento", + type=click.STRING, + required=False, +) +@click.option( + "-n", + "--name", + type=click.STRING, + help="Deployment name", +) +@click.option( + "--cluster", + type=click.STRING, + help="Name of the cluster", +) +@click.option( + "--access-authorization", + type=click.BOOL, + help="Enable access authorization", +) +@click.option( + "--scaling-min", + type=click.INT, + help="Minimum scaling value", +) +@click.option( + "--scaling-max", + type=click.INT, + help="Maximum scaling value", +) +@click.option( + "--instance-type", + type=click.STRING, + help="Type of instance", +) +@click.option( + "--strategy", + type=click.Choice( + [deployment_strategy.value for deployment_strategy in DeploymentStrategy] + ), + help="Deployment strategy", +) +@click.option( + "--env", + type=click.STRING, + help="List of environment variables pass by --env key=value, --env ...", + multiple=True, +) +@click.option( + "-f", + "--config-file", + type=click.File(), + help="Configuration file path", + default=None, +) +@click.option( + "-f", + "--config-file", + help="Configuration file path, mututally exclusive with other config options", + default=None, +) +@click.option( + "--config-dict", + type=click.STRING, + help="Configuration json string", + default=None, +) +@click.pass_obj +def apply( # type: ignore + shared_options: SharedOptions, + bento: str | None, + name: str | None, + cluster: str | None, + access_authorization: bool | None, + scaling_min: int | None, + scaling_max: int | None, + instance_type: str | None, + strategy: str | None, + env: tuple[str] | None, + config_file: str | t.TextIO | None, + config_dict: str | None, +) -> None: + """Apply a deployment on BentoCloud. + + \b + A deployment can be applied using config yaml file. + """ + cfg_dict = None + if config_dict is not None and config_dict != "": + cfg_dict = json.loads(config_dict) + config_params = DeploymentConfigParameters( + name=name, + context=shared_options.cloud_context, + bento=bento, + cluster=cluster, + access_authorization=access_authorization, + scaling_max=scaling_max, + scaling_min=scaling_min, + instance_type=instance_type, + strategy=strategy, + envs=[{"key": item.split("=")[0], "value": item.split("=")[1]} for item in env] + if env is not None + else None, + config_file=config_file, + config_dict=cfg_dict, ) - @click.pass_obj - def list( # type: ignore - shared_options: SharedOptions, - cluster: str | None, - search: str | None, - output: t.Literal["json", "yaml", "table"], - ) -> None: - """List existing deployments on BentoCloud.""" - d_list = Deployment.list( - context=shared_options.cloud_context, cluster=cluster, search=search + try: + config_params.verify() + except BentoMLException as e: + raise BentoMLException( + f"Failed to create deployment due to invalid configuration: {e}" ) - res: list[dict[str, t.Any]] = [d.to_dict() for d in d_list] - if output == "table": - table = Table(box=None) - table.add_column("Deployment") - table.add_column("created_at") - table.add_column("Bento") - table.add_column("Status") - table.add_column("Region") - for info in d_list: - table.add_row( - info.name, - info.created_at, - info.get_bento(refetch=False), - info.get_status(refetch=False).status, - info.cluster, - ) - console.print(table) - elif output == "json": - info = json.dumps(res, indent=2, default=str) - console.print_json(info) - else: - info = yaml.dump(res, indent=2, sort_keys=False) - console.print(Syntax(info, "yaml", background_color="default")) - - @deployment_cli.command() - @click.option( - "--cluster", type=click.STRING, default=None, help="Name of the cluster." + deployment_info = Deployment.apply( + deployment_config_params=config_params, + context=shared_options.cloud_context, ) - @click.option( - "-o", - "--output", - help="Display the output of this command.", - type=click.Choice(["json", "yaml", "table"]), - default="table", + + click.echo(f"Deployment '{deployment_info.name}' applied successfully.") + + +@deployment_command.command() +@click.argument( + "bento", + type=click.STRING, + required=False, +) +@click.option( + "-n", + "--name", + type=click.STRING, + help="Deployment name", +) +@click.option( + "--cluster", + type=click.STRING, + help="Name of the cluster", +) +@click.option( + "--access-authorization", + type=click.BOOL, + help="Enable access authorization", +) +@click.option( + "--scaling-min", + type=click.INT, + help="Minimum scaling value", +) +@click.option( + "--scaling-max", + type=click.INT, + help="Maximum scaling value", +) +@click.option( + "--instance-type", + type=click.STRING, + help="Type of instance", +) +@click.option( + "--strategy", + type=click.Choice( + [deployment_strategy.value for deployment_strategy in DeploymentStrategy] + ), + help="Deployment strategy", +) +@click.option( + "--env", + type=click.STRING, + help="List of environment variables pass by --env key=value, --env ...", + multiple=True, +) +@click.option( + "-f", + "--config-file", + type=click.File(), + help="Configuration file path", + default=None, +) +@click.option( + "--config-dict", + type=click.STRING, + help="Configuration json string", + default=None, +) +@click.option( + "--wait/--no-wait", + type=click.BOOL, + is_flag=True, + help="Do not wait for deployment to be ready", + default=True, +) +@click.option( + "--timeout", + type=click.INT, + default=1800, + help="Timeout for deployment to be ready in seconds", +) +@click.pass_obj +def create( + shared_options: SharedOptions, + bento: str | None, + name: str | None, + cluster: str | None, + access_authorization: bool | None, + scaling_min: int | None, + scaling_max: int | None, + instance_type: str | None, + strategy: str | None, + env: tuple[str] | None, + config_file: str | t.TextIO | None, + config_dict: str | None, + wait: bool, + timeout: int, +) -> None: + """Create a deployment on BentoCloud. + + \b + Create a deployment using parameters, or using config yaml file. + """ + create_deployment( + context=shared_options.cloud_context, + bento=bento, + name=name, + cluster=cluster, + access_authorization=access_authorization, + scaling_min=scaling_min, + scaling_max=scaling_max, + instance_type=instance_type, + strategy=strategy, + env=env, + config_file=config_file, + config_dict=config_dict, + wait=wait, + timeout=timeout, ) - @click.pass_obj - def list_instance_types( # type: ignore - shared_options: SharedOptions, - cluster: str | None, - output: t.Literal["json", "yaml", "table"], - ) -> None: - """List existing instance types in cluster on BentoCloud.""" - d_list = Deployment.list_instance_types( - context=shared_options.cloud_context, cluster=cluster - ) - res: list[dict[str, t.Any]] = [d.to_dict() for d in d_list] - if output == "table": - table = Table(box=None) - table.add_column("Name") - table.add_column("Price") - table.add_column("CPU") - table.add_column("Memory") - table.add_column("GPU") - table.add_column("GPU Type") - for info in d_list: - table.add_row( - info.name, - info.price, - info.cpu, - info.memory, - info.gpu, - info.gpu_type, - ) - console.print(table) - elif output == "json": - info = json.dumps(res, indent=2, default=str) - console.print_json(info) - else: - info = yaml.dump(res, indent=2, sort_keys=False) - console.print(Syntax(info, "yaml", background_color="default")) + + +@deployment_command.command() +@shared_decorator +@click.argument( + "name", + type=click.STRING, + required=True, +) +@output_option +@click.pass_obj +def get( # type: ignore + shared_options: SharedOptions, + name: str, + cluster: str | None, + output: t.Literal["json", "default"], +) -> None: + """Get a deployment on BentoCloud.""" + d = Deployment.get(name, context=shared_options.cloud_context, cluster=cluster) + if output == "json": + info = json.dumps(d.to_dict(), indent=2, default=str) + console.print_json(info) + else: + info = yaml.dump(d.to_dict(), indent=2, sort_keys=False) + console.print(Syntax(info, "yaml", background_color="default")) + + +@deployment_command.command() +@shared_decorator +@click.argument( + "name", + type=click.STRING, + required=True, +) +@click.pass_obj +def terminate( # type: ignore + shared_options: SharedOptions, + name: str, + cluster: str | None, +) -> None: + """Terminate a deployment on BentoCloud.""" + Deployment.terminate(name, context=shared_options.cloud_context, cluster=cluster) + click.echo(f"Deployment '{name}' terminated successfully.") + + +@deployment_command.command() +@click.argument( + "name", + type=click.STRING, + required=True, +) +@shared_decorator +@click.pass_obj +def delete( # type: ignore + shared_options: SharedOptions, + name: str, + cluster: str | None, +) -> None: + """Delete a deployment on BentoCloud.""" + Deployment.delete(name, context=shared_options.cloud_context, cluster=cluster) + click.echo(f"Deployment '{name}' deleted successfully.") + + +@deployment_command.command(name="list") +@click.option("--cluster", type=click.STRING, default=None, help="Name of the cluster.") +@click.option( + "--search", type=click.STRING, default=None, help="Search for list request." +) +@click.option( + "-o", + "--output", + help="Display the output of this command.", + type=click.Choice(["json", "yaml", "table"]), + default="table", +) +@click.pass_obj +def list_command( # type: ignore + shared_options: SharedOptions, + cluster: str | None, + search: str | None, + output: t.Literal["json", "yaml", "table"], +) -> None: + """List existing deployments on BentoCloud.""" + d_list = Deployment.list( + context=shared_options.cloud_context, cluster=cluster, search=search + ) + res: list[dict[str, t.Any]] = [d.to_dict() for d in d_list] + if output == "table": + table = Table(box=None) + table.add_column("Deployment") + table.add_column("created_at") + table.add_column("Bento") + table.add_column("Status") + table.add_column("Region") + for info in d_list: + table.add_row( + info.name, + info.created_at, + info.get_bento(refetch=False), + info.get_status(refetch=False).status, + info.cluster, + ) + console.print(table) + elif output == "json": + info = json.dumps(res, indent=2, default=str) + console.print_json(info) + else: + info = yaml.dump(res, indent=2, sort_keys=False) + console.print(Syntax(info, "yaml", background_color="default")) + + +@deployment_command.command() +@click.option("--cluster", type=click.STRING, default=None, help="Name of the cluster.") +@click.option( + "-o", + "--output", + help="Display the output of this command.", + type=click.Choice(["json", "yaml", "table"]), + default="table", +) +@click.pass_obj +def list_instance_types( # type: ignore + shared_options: SharedOptions, + cluster: str | None, + output: t.Literal["json", "yaml", "table"], +) -> None: + """List existing instance types in cluster on BentoCloud.""" + d_list = Deployment.list_instance_types( + context=shared_options.cloud_context, cluster=cluster + ) + res: list[dict[str, t.Any]] = [d.to_dict() for d in d_list] + if output == "table": + table = Table(box=None) + table.add_column("Name") + table.add_column("Price") + table.add_column("CPU") + table.add_column("Memory") + table.add_column("GPU") + table.add_column("GPU Type") + for info in d_list: + table.add_row( + info.name, + info.price, + info.cpu, + info.memory, + info.gpu, + info.gpu_type, + ) + console.print(table) + elif output == "json": + info = json.dumps(res, indent=2, default=str) + console.print_json(info) + else: + info = yaml.dump(res, indent=2, sort_keys=False) + console.print(Syntax(info, "yaml", background_color="default")) def create_deployment( diff --git a/src/bentoml_cli/env.py b/src/bentoml_cli/env.py index 54d00855cb4..b27ad6cabea 100644 --- a/src/bentoml_cli/env.py +++ b/src/bentoml_cli/env.py @@ -12,6 +12,10 @@ import click +from bentoml._internal.utils.pkg import PackageNotFoundError +from bentoml._internal.utils.pkg import get_pkg_version +from bentoml.exceptions import CLIException + conda_packages_name = "conda_packages" _ENVVAR = [ @@ -104,67 +108,62 @@ def pretty_format( return "\n".join({"md": format_md, "bash": format_bash}[output](env, info_dict)) -def add_env_command(cli: click.Group) -> None: - from bentoml._internal.utils.pkg import PackageNotFoundError - from bentoml._internal.utils.pkg import get_pkg_version - from bentoml.exceptions import CLIException - - @cli.command(help=gettext("Print environment info and exit")) - @click.option( - "-o", - "--output", - type=click.Choice(["md", "bash"]), - default="md", - show_default=True, - help="Output format. '-o bash' to display without format.", - ) - @click.pass_context - def env(ctx: click.Context, output: t.Literal["md", "bash"]) -> None: # type: ignore (unused warning) - if output not in ["md", "bash"]: - raise CLIException(f"Unknown output format: {output}") - - is_windows = sys.platform == "win32" - - info_dict: dict[str, str | list[str]] = { - "bentoml": importlib.metadata.version("bentoml"), - "python": platform.python_version(), - "platform": platform.platform(), - } - - if is_windows: - from ctypes import windll - - # https://stackoverflow.com/a/1026626 - is_admin: bool = windll.shell32.IsUserAnAdmin() != 0 - info_dict["is_window_admin"] = str(is_admin) - else: - info_dict["uid_gid"] = f"{os.geteuid()}:{os.getegid()}" - - if "CONDA_PREFIX" in os.environ: - # conda packages - conda_like = None - for possible_exec in ["conda", "mamba", "micromamba"]: - if shutil.which(possible_exec) is not None: - conda_like = possible_exec - break - assert ( - conda_like is not None - ), "couldn't find a conda-like executable, while CONDA_PREFIX is set." - conda_packages = run_cmd([conda_like, "env", "export"]) - - # user is currently in a conda environment, - # doing this is faster than invoking `conda --version` - try: - conda_version = get_pkg_version("conda") - except PackageNotFoundError: - conda_version = run_cmd([conda_like, "--version"])[0].split(" ")[-1] - - info_dict[conda_like] = conda_version - info_dict["in_conda_env"] = str(True) - info_dict["conda_packages"] = conda_packages - - # process info from `pip freeze` - pip_packages = run_cmd([sys.executable, "-m", "pip", "freeze"]) - info_dict["pip_packages"] = pip_packages - click.echo(pretty_format(info_dict, output=output)) - ctx.exit(0) +@click.command(help=gettext("Print environment info and exit"), name="env") +@click.option( + "-o", + "--output", + type=click.Choice(["md", "bash"]), + default="md", + show_default=True, + help="Output format. '-o bash' to display without format.", +) +@click.pass_context +def env_command(ctx: click.Context, output: t.Literal["md", "bash"]) -> None: # type: ignore (unused warning) + if output not in ["md", "bash"]: + raise CLIException(f"Unknown output format: {output}") + + is_windows = sys.platform == "win32" + + info_dict: dict[str, str | list[str]] = { + "bentoml": importlib.metadata.version("bentoml"), + "python": platform.python_version(), + "platform": platform.platform(), + } + + if is_windows: + from ctypes import windll + + # https://stackoverflow.com/a/1026626 + is_admin: bool = windll.shell32.IsUserAnAdmin() != 0 + info_dict["is_window_admin"] = str(is_admin) + else: + info_dict["uid_gid"] = f"{os.geteuid()}:{os.getegid()}" + + if "CONDA_PREFIX" in os.environ: + # conda packages + conda_like = None + for possible_exec in ["conda", "mamba", "micromamba"]: + if shutil.which(possible_exec) is not None: + conda_like = possible_exec + break + assert ( + conda_like is not None + ), "couldn't find a conda-like executable, while CONDA_PREFIX is set." + conda_packages = run_cmd([conda_like, "env", "export"]) + + # user is currently in a conda environment, + # doing this is faster than invoking `conda --version` + try: + conda_version = get_pkg_version("conda") + except PackageNotFoundError: + conda_version = run_cmd([conda_like, "--version"])[0].split(" ")[-1] + + info_dict[conda_like] = conda_version + info_dict["in_conda_env"] = str(True) + info_dict["conda_packages"] = conda_packages + + # process info from `pip freeze` + pip_packages = run_cmd([sys.executable, "-m", "pip", "freeze"]) + info_dict["pip_packages"] = pip_packages + click.echo(pretty_format(info_dict, output=output)) + ctx.exit(0) diff --git a/src/bentoml_cli/models.py b/src/bentoml_cli/models.py index adf75a3060a..6105df6164c 100644 --- a/src/bentoml_cli/models.py +++ b/src/bentoml_cli/models.py @@ -7,16 +7,31 @@ import yaml from rich.syntax import Syntax from rich.table import Table - +from simple_di import Provide +from simple_di import inject + +from bentoml import Tag +from bentoml._internal.bento.bento import DEFAULT_BENTO_BUILD_FILE +from bentoml._internal.bento.build_config import BentoBuildConfig +from bentoml._internal.container import BentoMLContainer +from bentoml._internal.utils import calc_dir_size +from bentoml._internal.utils import human_readable_size +from bentoml._internal.utils import resolve_user_filepath +from bentoml._internal.utils import rich_console as console +from bentoml.exceptions import InvalidArgument +from bentoml.models import import_model from bentoml_cli.utils import BentoMLCommandGroup from bentoml_cli.utils import is_valid_bento_name from bentoml_cli.utils import is_valid_bento_tag if t.TYPE_CHECKING: from click import Context - from click import Group from click import Parameter + from bentoml._internal.bento import BentoStore + from bentoml._internal.cloud import BentoCloudClient + from bentoml._internal.models import ModelStore + from .utils import SharedOptions @@ -43,282 +58,305 @@ def parse_delete_targets_argument_callback( return delete_targets -def add_model_management_commands(cli: Group) -> None: - from bentoml import Tag - from bentoml._internal.bento.bento import DEFAULT_BENTO_BUILD_FILE - from bentoml._internal.bento.build_config import BentoBuildConfig - from bentoml._internal.configuration.containers import BentoMLContainer - from bentoml._internal.utils import calc_dir_size - from bentoml._internal.utils import human_readable_size - from bentoml._internal.utils import resolve_user_filepath - from bentoml._internal.utils import rich_console as console - from bentoml.exceptions import InvalidArgument - from bentoml.models import import_model - - model_store = BentoMLContainer.model_store.get() - cloud_client = BentoMLContainer.bentocloud_client.get() - bento_store = BentoMLContainer.bento_store.get() - - @cli.group(name="models", cls=BentoMLCommandGroup) - def model_cli(): - """Model Subcommands Groups""" - - @model_cli.command() - @click.argument("model_tag", type=click.STRING) - @click.option( - "-o", - "--output", - type=click.Choice(["json", "yaml", "path"]), - default="yaml", - ) - def get(model_tag: str, output: str) -> None: # type: ignore (not accessed) - """Print Model details by providing the model_tag - - \b - bentoml get iris_clf:qojf5xauugwqtgxi - bentoml get iris_clf:qojf5xauugwqtgxi --output=json - """ - model = model_store.get(model_tag) - - if output == "path": - console.print(model.path) - elif output == "json": - info = json.dumps(model.info.to_dict(), indent=2, default=str) - console.print_json(info) - else: - console.print( - Syntax(str(model.info.dump()), "yaml", background_color="default") - ) +@click.group(name="models", cls=BentoMLCommandGroup) +def model_command(): + """Model Subcommands Groups""" + + +@model_command.command() +@click.argument("model_tag", type=click.STRING) +@click.option( + "-o", + "--output", + type=click.Choice(["json", "yaml", "path"]), + default="yaml", +) +@inject +def get( + model_tag: str, + output: str, + model_store: ModelStore = Provide[BentoMLContainer.model_store], +) -> None: # type: ignore (not accessed) + """Print Model details by providing the model_tag + + \b + bentoml get iris_clf:qojf5xauugwqtgxi + bentoml get iris_clf:qojf5xauugwqtgxi --output=json + """ + model = model_store.get(model_tag) + + if output == "path": + console.print(model.path) + elif output == "json": + info = json.dumps(model.info.to_dict(), indent=2, default=str) + console.print_json(info) + else: + console.print( + Syntax(str(model.info.dump()), "yaml", background_color="default") + ) - @model_cli.command(name="list") - @click.argument("model_name", type=click.STRING, required=False) - @click.option( - "-o", - "--output", - type=click.Choice(["json", "yaml", "table"]), - default="table", - ) - def list_models(model_name: str, output: str) -> None: # type: ignore (not accessed) - """List Models in local store - - \b - # show all models saved - $ bentoml models list - - \b - # show all versions of bento with the name FraudDetector - $ bentoml models list FraudDetector - """ - - models = model_store.list(model_name) - res = [ - { - "tag": str(model.tag), - "module": model.info.module, - "size": human_readable_size(calc_dir_size(model.path)), - "creation_time": model.info.creation_time.astimezone().strftime( - "%Y-%m-%d %H:%M:%S" - ), - } - for model in sorted( - models, key=lambda x: x.info.creation_time, reverse=True + +@model_command.command(name="list") +@click.argument("model_name", type=click.STRING, required=False) +@click.option( + "-o", + "--output", + type=click.Choice(["json", "yaml", "table"]), + default="table", +) +@inject +def list_models( + model_name: str, + output: str, + model_store: ModelStore = Provide[BentoMLContainer.model_store], +) -> None: # type: ignore (not accessed) + """List Models in local store + + \b + # show all models saved + $ bentoml models list + + \b + # show all versions of bento with the name FraudDetector + $ bentoml models list FraudDetector + """ + + models = model_store.list(model_name) + res = [ + { + "tag": str(model.tag), + "module": model.info.module, + "size": human_readable_size(calc_dir_size(model.path)), + "creation_time": model.info.creation_time.astimezone().strftime( + "%Y-%m-%d %H:%M:%S" + ), + } + for model in sorted(models, key=lambda x: x.info.creation_time, reverse=True) + ] + if output == "json": + info = json.dumps(res, indent=2) + console.print_json(info) + elif output == "yaml": + info = yaml.safe_dump(res, indent=2) + console.print(Syntax(info, "yaml")) + else: + table = Table(box=None) + table.add_column("Tag") + table.add_column("Module") + table.add_column("Size") + table.add_column("Creation Time") + for model in res: + table.add_row( + model["tag"], + model["module"], + model["size"], + model["creation_time"], ) - ] - if output == "json": - info = json.dumps(res, indent=2) - console.print_json(info) - elif output == "yaml": - info = yaml.safe_dump(res, indent=2) - console.print(Syntax(info, "yaml"), background_color="default") - else: - table = Table(box=None) - table.add_column("Tag") - table.add_column("Module") - table.add_column("Size") - table.add_column("Creation Time") - for model in res: - table.add_row( - model["tag"], - model["module"], - model["size"], - model["creation_time"], - ) - console.print(table) - - @model_cli.command() - @click.argument( - "delete_targets", - nargs=-1, - callback=parse_delete_targets_argument_callback, - required=True, - ) - @click.option( - "-y", - "--yes", - "--assume-yes", - is_flag=True, - help="Skip confirmation when deleting a specific model", - ) - def delete(delete_targets: str, yes: bool) -> None: # type: ignore (not accessed) - """Delete Model in local model store. - - \b - Examples: - * Delete single model by "name:version", e.g: `bentoml models delete iris_clf:v1` - * Bulk delete all models with a specific name, e.g.: `bentoml models delete iris_clf` - * Bulk delete multiple models by name and version, separated by ",", e.g.: `bentoml models delete iris_clf:v1,iris_clf:v2` - * Bulk delete multiple models by name and version, separated by " ", e.g.: `bentoml models delete iris_clf:v1 iris_clf:v2` - * Bulk delete without confirmation, e.g.: `bentoml models delete IrisClassifier --yes` - """ # noqa - from bentoml.exceptions import BentoMLException - - def check_model_is_used(tag: Tag) -> None: - in_use: list[Tag] = [] - for bento in bento_store.list(): - if bento._model_store is not None: - continue - if any(model.tag == tag for model in bento.info.all_models): - in_use.append(bento.tag) - if in_use: - raise BentoMLException( - f"Model {tag} is being used by the following bentos and can't be deleted:\n " - + "\n ".join(map(str, in_use)) - ) - - def delete_target(target: str) -> None: - tag = Tag.from_str(target) - - if tag.version is None: - to_delete_models = model_store.list(target) - else: - to_delete_models = [model_store.get(tag)] - - for model in to_delete_models: - if yes: - delete_confirmed = True - else: - delete_confirmed = click.confirm(f"delete model {model.tag}?") - - if delete_confirmed: - check_model_is_used(model.tag) - model_store.delete(model.tag) - click.echo(f"{model} deleted.") - - for target in delete_targets: - delete_target(target) - - @model_cli.command() - @click.argument("model_tag", type=click.STRING) - @click.argument("out_path", type=click.STRING, default="", required=False) - def export(model_tag: str, out_path: str) -> None: # type: ignore (not accessed) - """Export a Model to an external archive file - - arguments: - - \b - MODEL_TAG: model identifier - OUT_PATH: output path of exported model. - If this argument is not provided, model is exported to name-version.bentomodel in the current directory. - Besides native .bentomodel format, we also support formats like tar('tar'), tar.gz ('gz'), tar.xz ('xz'), tar.bz2 ('bz2'), and zip. - - examples: - - \b - bentoml models export FraudDetector:latest - bentoml models export FraudDetector:latest ./my_model.bentomodel - bentoml models export FraudDetector:20210709_DE14C9 ./my_model.bentomodel - bentoml models export FraudDetector:20210709_DE14C9 s3://mybucket/models/my_model.bentomodel - """ - bentomodel = model_store.get(model_tag) - out_path = bentomodel.export(out_path) - click.echo(f"{bentomodel} exported to {out_path}.") - - @model_cli.command(name="import") - @click.argument("model_path", type=click.STRING) - def import_from(model_path: str) -> None: # type: ignore (not accessed) - """Import a previously exported Model archive file - - bentoml models import ./my_model.bentomodel - bentoml models import s3://mybucket/models/my_model.bentomodel - """ - bentomodel = import_model(model_path) - click.echo(f"{bentomodel} imported.") - - @model_cli.command() - @click.argument("model_tag", type=click.STRING, required=False) - @click.option( - "-f", - "--force", - is_flag=True, - default=False, - help="Force pull from remote model store to local and overwrite even if it already exists in local", - ) - @click.option( - "-F", - "--bentofile", - type=click.STRING, - default=DEFAULT_BENTO_BUILD_FILE, - help="Path to bentofile. Default to 'bentofile.yaml'", - ) - @click.pass_context - def pull(ctx: click.Context, model_tag: str | None, force: bool, bentofile: str): # type: ignore (not accessed) - """Pull Model from a remote model store. If model_tag is not provided, - it will pull models defined in bentofile.yaml. - """ - from click.core import ParameterSource - - if model_tag is not None: - if ctx.get_parameter_source("bentofile") != ParameterSource.DEFAULT: - click.echo("-f bentofile is ignored when model_tag is provided") - cloud_client.pull_model( - model_tag, force=force, context=ctx.obj.cloud_context + console.print(table) + + +@model_command.command() +@click.argument( + "delete_targets", + nargs=-1, + callback=parse_delete_targets_argument_callback, + required=True, +) +@click.option( + "-y", + "--yes", + "--assume-yes", + is_flag=True, + help="Skip confirmation when deleting a specific model", +) +@inject +def delete( + delete_targets: str, + yes: bool, + model_store: ModelStore = Provide[BentoMLContainer.model_store], + bento_store: BentoStore = Provide[BentoMLContainer.bento_store], +) -> None: # type: ignore (not accessed) + """Delete Model in local model store. + + \b + Examples: + * Delete single model by "name:version", e.g: `bentoml models delete iris_clf:v1` + * Bulk delete all models with a specific name, e.g.: `bentoml models delete iris_clf` + * Bulk delete multiple models by name and version, separated by ",", e.g.: `bentoml models delete iris_clf:v1,iris_clf:v2` + * Bulk delete multiple models by name and version, separated by " ", e.g.: `bentoml models delete iris_clf:v1 iris_clf:v2` + * Bulk delete without confirmation, e.g.: `bentoml models delete IrisClassifier --yes` + """ # noqa + from bentoml.exceptions import BentoMLException + + def check_model_is_used(tag: Tag) -> None: + in_use: list[Tag] = [] + for bento in bento_store.list(): + if bento._model_store is not None: + continue + if any(model.tag == tag for model in bento.info.all_models): + in_use.append(bento.tag) + if in_use: + raise BentoMLException( + f"Model {tag} is being used by the following bentos and can't be deleted:\n " + + "\n ".join(map(str, in_use)) ) - return - try: - bentofile = resolve_user_filepath(bentofile, None) - except FileNotFoundError: - raise InvalidArgument(f'bentofile "{bentofile}" not found') + def delete_target(target: str) -> None: + tag = Tag.from_str(target) - with open(bentofile, "r", encoding="utf-8") as f: - build_config = BentoBuildConfig.from_yaml(f) - - if not build_config.models: - raise InvalidArgument( - "No model to pull, please provide a model tag or define models in bentofile.yaml" - ) - for model_spec in build_config.models: - cloud_client.pull_model( - model_spec.tag, - force=force, - context=t.cast("SharedOptions", ctx.obj).cloud_context, - query=model_spec.filter, - ) + if tag.version is None: + to_delete_models = model_store.list(target) + else: + to_delete_models = [model_store.get(tag)] - @model_cli.command() - @click.argument("model_tag", type=click.STRING) - @click.option( - "-f", - "--force", - is_flag=True, - default=False, - help="Forced push to remote model store even if it exists in remote", - ) - @click.option( - "-t", - "--threads", - default=10, - help="Number of threads to use for upload", - ) - @click.pass_obj - def push(shared_options: SharedOptions, model_tag: str, force: bool, threads: int): # type: ignore (not accessed) - """Push Model to a remote model store.""" - model_obj = model_store.get(model_tag) - if not model_obj: - raise click.ClickException(f"Model {model_tag} not found in local store") - cloud_client.push_model( - model_obj, + for model in to_delete_models: + if yes: + delete_confirmed = True + else: + delete_confirmed = click.confirm(f"delete model {model.tag}?") + + if delete_confirmed: + check_model_is_used(model.tag) + model_store.delete(model.tag) + click.echo(f"{model} deleted.") + + for target in delete_targets: + delete_target(target) + + +@model_command.command() +@click.argument("model_tag", type=click.STRING) +@click.argument("out_path", type=click.STRING, default="", required=False) +@inject +def export( + model_tag: str, + out_path: str, + model_store: ModelStore = Provide[BentoMLContainer.model_store], +) -> None: # type: ignore (not accessed) + """Export a Model to an external archive file + + arguments: + + \b + MODEL_TAG: model identifier + OUT_PATH: output path of exported model. + If this argument is not provided, model is exported to name-version.bentomodel in the current directory. + Besides native .bentomodel format, we also support formats like tar('tar'), tar.gz ('gz'), tar.xz ('xz'), tar.bz2 ('bz2'), and zip. + + examples: + + \b + bentoml models export FraudDetector:latest + bentoml models export FraudDetector:latest ./my_model.bentomodel + bentoml models export FraudDetector:20210709_DE14C9 ./my_model.bentomodel + bentoml models export FraudDetector:20210709_DE14C9 s3://mybucket/models/my_model.bentomodel + """ + bentomodel = model_store.get(model_tag) + out_path = bentomodel.export(out_path) + click.echo(f"{bentomodel} exported to {out_path}.") + + +@model_command.command(name="import") +@click.argument("model_path", type=click.STRING) +def import_from(model_path: str) -> None: # type: ignore (not accessed) + """Import a previously exported Model archive file + + bentoml models import ./my_model.bentomodel + bentoml models import s3://mybucket/models/my_model.bentomodel + """ + bentomodel = import_model(model_path) + click.echo(f"{bentomodel} imported.") + + +@model_command.command() +@click.argument("model_tag", type=click.STRING, required=False) +@click.option( + "-f", + "--force", + is_flag=True, + default=False, + help="Force pull from remote model store to local and overwrite even if it already exists in local", +) +@click.option( + "-F", + "--bentofile", + type=click.STRING, + default=DEFAULT_BENTO_BUILD_FILE, + help="Path to bentofile. Default to 'bentofile.yaml'", +) +@click.pass_context +@inject +def pull( + ctx: click.Context, + model_tag: str | None, + force: bool, + bentofile: str, + cloud_client: BentoCloudClient = Provide[BentoMLContainer.bentocloud_client], +): + """Pull Model from a remote model store. If model_tag is not provided, + it will pull models defined in bentofile.yaml. + """ + from click.core import ParameterSource + + if model_tag is not None: + if ctx.get_parameter_source("bentofile") != ParameterSource.DEFAULT: + click.echo("-f bentofile is ignored when model_tag is provided") + cloud_client.pull_model(model_tag, force=force, context=ctx.obj.cloud_context) + return + + try: + bentofile = resolve_user_filepath(bentofile, None) + except FileNotFoundError: + raise InvalidArgument(f'bentofile "{bentofile}" not found') + + with open(bentofile, "r", encoding="utf-8") as f: + build_config = BentoBuildConfig.from_yaml(f) + + if not build_config.models: + raise InvalidArgument( + "No model to pull, please provide a model tag or define models in bentofile.yaml" + ) + for model_spec in build_config.models: + cloud_client.pull_model( + model_spec.tag, force=force, - threads=threads, - context=shared_options.cloud_context, + context=t.cast("SharedOptions", ctx.obj).cloud_context, + query=model_spec.filter, ) + + +@model_command.command() +@click.argument("model_tag", type=click.STRING) +@click.option( + "-f", + "--force", + is_flag=True, + default=False, + help="Forced push to remote model store even if it exists in remote", +) +@click.option( + "-t", + "--threads", + default=10, + help="Number of threads to use for upload", +) +@click.pass_obj +@inject +def push( + shared_options: SharedOptions, + model_tag: str, + force: bool, + threads: int, + model_store: ModelStore = Provide[BentoMLContainer.model_store], + cloud_client: BentoCloudClient = Provide[BentoMLContainer.bentocloud_client], +): # type: ignore (not accessed) + """Push Model to a remote model store.""" + model_obj = model_store.get(model_tag) + if not model_obj: + raise click.ClickException(f"Model {model_tag} not found in local store") + cloud_client.push_model( + model_obj, + force=force, + threads=threads, + context=shared_options.cloud_context, + ) diff --git a/src/bentoml_cli/serve.py b/src/bentoml_cli/serve.py index 02c10ffde01..05881e2a75b 100644 --- a/src/bentoml_cli/serve.py +++ b/src/bentoml_cli/serve.py @@ -46,13 +46,19 @@ def decorator(f: F[t.Any]) -> t.Callable[[F[t.Any]], click.Command]: return decorator -def add_serve_command(cli: click.Group) -> None: +def build_serve_command() -> click.Group: from bentoml._internal.configuration.containers import BentoMLContainer from bentoml._internal.log import configure_server_logging from bentoml.grpc.utils import LATEST_PROTOCOL_VERSION from bentoml_cli.env_manager import env_manager + from bentoml_cli.utils import AliasCommand + from bentoml_cli.utils import BentoMLCommandGroup - @cli.command(aliases=["serve-http"]) + @click.group(name="serve", cls=BentoMLCommandGroup) + def cli(): + pass + + @cli.command(aliases=["serve-http"], cls=AliasCommand) @click.argument("bento", type=click.STRING, default=".") @click.option( "--development", @@ -526,3 +532,8 @@ def serve_grpc( # type: ignore (unused warning) reload=reload, development_mode=False, ) + + return cli + + +serve_command = build_serve_command() diff --git a/src/bentoml_cli/start.py b/src/bentoml_cli/start.py index 221b74f4f4e..0cd1859fb87 100644 --- a/src/bentoml_cli/start.py +++ b/src/bentoml_cli/start.py @@ -11,10 +11,15 @@ logger = logging.getLogger(__name__) -def add_start_command(cli: click.Group) -> None: +def build_start_command() -> click.Group: from bentoml._internal.configuration.containers import BentoMLContainer from bentoml._internal.utils import add_experimental_docstring from bentoml.grpc.utils import LATEST_PROTOCOL_VERSION + from bentoml_cli.utils import BentoMLCommandGroup + + @click.group(name="start", cls=BentoMLCommandGroup) + def cli(): + pass @cli.command(hidden=True) @click.argument("bento", type=click.STRING, default=".") @@ -480,3 +485,8 @@ def start_runner_server( # type: ignore (unused warning) host=host, backlog=backlog, ) + + return cli + + +start_command = build_start_command() diff --git a/src/bentoml_cli/utils.py b/src/bentoml_cli/utils.py index 2efce086ad8..48b1f996d5b 100644 --- a/src/bentoml_cli/utils.py +++ b/src/bentoml_cli/utils.py @@ -17,7 +17,6 @@ if t.TYPE_CHECKING: from click import Command from click import Context - from click import Group from click import HelpFormatter from click import Option from click import Parameter @@ -33,7 +32,7 @@ class ClickFunctionWrapper(t.Protocol[P]): def __call__( # pylint: disable=no-method-argument *args: P.args, **kwargs: P.kwargs, - ) -> F[P]: + ) -> t.Any: ... WrappedCLI = t.Callable[P, ClickFunctionWrapper[P]] @@ -197,11 +196,48 @@ class SharedOptions: """This is the click.Context object that will be used in BentoML CLI.""" cloud_context: str | None = attr.field(default=None) + do_not_track: bool = attr.field(default=False) def with_options(self, **attrs: t.Any) -> t.Any: return attr.evolve(self, **attrs) +def setup_verbosity(ctx: Context, param: Parameter, value: int) -> int: + from bentoml._internal.configuration import set_debug_mode + from bentoml._internal.configuration import set_quiet_mode + from bentoml._internal.log import configure_logging + + if value == 1: + set_debug_mode(True) + elif value == -1: + set_quiet_mode(True) + + configure_logging() + return value + + +def setup_track(ctx: Context, param: Parameter, value: bool) -> bool: + if value: + obj = ctx.ensure_object(SharedOptions) + obj.do_not_track = True + return value + + +def setup_cloud_client(ctx: Context, param: Parameter, value: str | None) -> str | None: + if value: + obj = ctx.ensure_object(SharedOptions) + obj.cloud_context = value + return value + + +class AliasCommand(click.Command): + def __init__( + self, *args: t.Any, aliases: list[str] | None = None, **kwargs: t.Any + ) -> None: + super(AliasCommand, self).__init__(*args, **kwargs) + self.aliases = aliases or [] + + class BentoMLCommandGroup(click.Group): """ Click command class customized for BentoML CLI, allow specifying a default @@ -226,87 +262,69 @@ def serve(): ... NUMBER_OF_COMMON_PARAMS = 5 # NOTE: 4 shared options and a option group title @staticmethod - def bentoml_common_params(func: F[P]) -> WrappedCLI[bool, bool, str | None]: + def bentoml_common_params(f: F[P]) -> ClickFunctionWrapper[P]: # NOTE: update NUMBER_OF_COMMON_PARAMS when adding option. from bentoml._internal.configuration import DEBUG_ENV_VAR from bentoml._internal.configuration import QUIET_ENV_VAR - from bentoml._internal.configuration import set_debug_mode - from bentoml._internal.configuration import set_quiet_mode - from bentoml._internal.log import configure_logging from bentoml._internal.utils.analytics import BENTOML_DO_NOT_TRACK - @cog.optgroup.group("Global options") - @cog.optgroup.option( + f = cog.optgroup.option( "-q", "--quiet", - is_flag=True, - default=False, + "verbosity", + flag_value=-1, + default=0, + expose_value=False, envvar=QUIET_ENV_VAR, help="Suppress all warnings and info logs", - ) - @cog.optgroup.option( + callback=setup_verbosity, + is_eager=True, + )(f) + f = cog.optgroup.option( "--verbose", "--debug", - "verbose", - is_flag=True, - default=False, + "verbosity", + flag_value=1, + expose_value=False, envvar=DEBUG_ENV_VAR, help="Generate debug information", - ) - @cog.optgroup.option( + )(f) + f = cog.optgroup.option( "--do-not-track", is_flag=True, default=False, envvar=BENTOML_DO_NOT_TRACK, + expose_value=False, help="Do not send usage info", - ) - @cog.optgroup.option( + callback=setup_track, + )(f) + f = cog.optgroup.option( "--context", "cloud_context", type=click.STRING, default=None, help="BentoCloud context name.", - ) - @click.pass_context - @functools.wraps(func) - def wrapper( - ctx: click.Context, - quiet: bool, - verbose: bool, - cloud_context: str | None, - *args: P.args, - **kwargs: P.kwargs, - ) -> t.Any: - ctx.obj = SharedOptions(cloud_context=cloud_context) - if quiet: - set_quiet_mode(True) - if verbose: - logger.warning("'--quiet' passed; ignoring '--verbose/--debug'") - elif verbose: - set_debug_mode(True) - - configure_logging() - - return func(*args, **kwargs) - - return wrapper + expose_value=False, + callback=setup_cloud_client, + )(f) + f = cog.optgroup.group("Global options")(f) + return t.cast("ClickFunctionWrapper[P]", f) @staticmethod def bentoml_track_usage( - func: F[P] | WrappedCLI[bool, bool, str | None], - cmd_group: click.Group, - **kwargs: t.Any, - ) -> WrappedCLI[bool]: + func: F[P], cmd_group: click.Group, name: str | None + ) -> F[P]: from bentoml._internal.utils.analytics import BENTOML_DO_NOT_TRACK from bentoml._internal.utils.analytics import CliEvent from bentoml._internal.utils.analytics import cli_events_map from bentoml._internal.utils.analytics import track - command_name = kwargs.get("name", func.__name__) + command_name = name or func.__name__ - @functools.wraps(func) - def wrapper(do_not_track: bool, *args: P.args, **kwargs: P.kwargs) -> t.Any: - if do_not_track: + @click.pass_context + def wrapper(ctx: Context, *args: P.args, **kwargs: P.kwargs) -> t.Any: + options = ctx.ensure_object(SharedOptions) + if options.do_not_track: os.environ[BENTOML_DO_NOT_TRACK] = str(True) return func(*args, **kwargs) @@ -349,12 +367,12 @@ def get_tracking_event(return_value: t.Any) -> CliEvent: @staticmethod def raise_click_exception( - func: F[P] | WrappedCLI[bool], cmd_group: click.Group, **kwargs: t.Any - ) -> ClickFunctionWrapper[t.Any]: + func: F[P], cmd_group: click.Group, name: str | None + ) -> F[P]: from bentoml._internal.configuration import get_debug_mode from bentoml.exceptions import BentoMLException - command_name = kwargs.get("name", func.__name__) + command_name = name or func.__name__ @functools.wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> t.Any: @@ -368,58 +386,49 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> t.Any: else: raise ClickException(click.style(msg, fg="red")) from err - return t.cast("ClickFunctionWrapper[t.Any]", wrapper) + return wrapper def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: + self.aliases = kwargs.pop("aliases", []) super(BentoMLCommandGroup, self).__init__(*args, **kwargs) # these two dictionaries will store known aliases for commands and groups self._commands: dict[str, list[str]] = {} self._aliases: dict[str, str] = {} - def command(self, *args: t.Any, **kwargs: t.Any) -> t.Callable[[F[P]], Command]: - if "context_settings" not in kwargs: - kwargs["context_settings"] = {} - kwargs["context_settings"]["max_content_width"] = 120 - aliases = kwargs.pop("aliases", None) - - def wrapper(func: F[P]) -> Command: - # add common parameters to command. - options = BentoMLCommandGroup.bentoml_common_params(func) - # Send tracking events before command finish. - usage = BentoMLCommandGroup.bentoml_track_usage(options, self, **kwargs) - # If BentoMLException raise ClickException instead before exit. - wrapped = BentoMLCommandGroup.raise_click_exception(usage, self, **kwargs) - - # move common parameters to end of the parameters list - wrapped.__click_params__ = ( - wrapped.__click_params__[-self.NUMBER_OF_COMMON_PARAMS :] - + wrapped.__click_params__[: -self.NUMBER_OF_COMMON_PARAMS] + def add_subcommands(self, group: click.Group) -> None: + if not isinstance(group, click.MultiCommand): + raise TypeError( + "BentoMLCommandGroup.add_subcommands only accepts click.MultiCommand" ) - cmd = super(BentoMLCommandGroup, self).command(*args, **kwargs)(wrapped) - # add aliases to a given commands if it is specified. - if aliases is not None: - assert cmd.name - self._commands[cmd.name] = aliases - self._aliases.update({alias: cmd.name for alias in aliases}) - return cmd - - return wrapper - - def group(self, *args: t.Any, **kwargs: t.Any) -> t.Callable[[F[P]], Group]: - aliases = kwargs.pop("aliases", None) - - def decorator(f: F[P]): - # create the main group - grp = super(BentoMLCommandGroup, self).group(*args, **kwargs)(f) - - if aliases is not None: - assert grp.name - self._commands[grp.name] = aliases - self._aliases.update({k: grp.name for k in aliases}) - - return grp - - return decorator + if isinstance(group, BentoMLCommandGroup): + # Common wrappers are already applied, call the super() method + for name, cmd in group.commands.items(): + super().add_command(cmd, name) + self._commands.update(group._commands) + self._aliases.update(group._aliases) + else: + for name, cmd in group.commands.items(): + self.add_command(cmd, name) + + def add_command(self, cmd: Command, name: str | None = None) -> None: + assert cmd.callback is not None + callback = BentoMLCommandGroup.bentoml_track_usage( + cmd.callback, self, name=cmd.name + ) + callback = BentoMLCommandGroup.raise_click_exception( + callback, self, name=cmd.name + ) + callback = BentoMLCommandGroup.bentoml_common_params(callback) + cmd.params.extend(reversed(callback.__click_params__)) + del callback.__click_params__ + cmd.callback = callback + cmd.context_settings["max_content_width"] = 120 + aliases = getattr(cmd, "aliases", None) + if aliases: + assert cmd.name + self._commands[cmd.name] = aliases + self._aliases.update({alias: cmd.name for alias in aliases}) + return super().add_command(cmd, name) def resolve_alias(self, cmd_name: str): return self._aliases[cmd_name] if cmd_name in self._aliases else cmd_name