refactor(cli): make CLI commands available as modules (#4487)

* feat(cli): make CLI commands avaiable as modules

Signed-off-by: Frost Ming <[email protected]>

* fix: handle bentoml group

Signed-off-by: Frost Ming <[email protected]>

* fix: simply aliases

Signed-off-by: Frost Ming <[email protected]>

* fix: rename bento_command

Signed-off-by: Frost Ming <[email protected]>

---------

Signed-off-by: Frost Ming <[email protected]>
This commit is contained in:
Frost Ming
2024-02-23 17:24:24 +08:00
committed by GitHub
parent ed91f8aa09
commit 7b0b0e6d4b
11 changed files with 1530 additions and 1436 deletions
+2
View File
@@ -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)
+58 -19
View File
@@ -18,7 +18,6 @@ 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
@@ -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 add_bento_management_commands(cli: Group):
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 add_bento_management_commands(cli: Group):
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 add_bento_management_commands(cli: Group):
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 add_bento_management_commands(cli: Group):
)
console.print(table)
@cli.command()
@bentos.command()
@click.argument(
"delete_targets",
nargs=-1,
@@ -172,7 +184,12 @@ def add_bento_management_commands(cli: Group):
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 add_bento_management_commands(cli: Group):
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 add_bento_management_commands(cli: Group):
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 add_bento_management_commands(cli: Group):
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 add_bento_management_commands(cli: Group):
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 add_bento_management_commands(cli: Group):
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 add_bento_management_commands(cli: Group):
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 add_bento_management_commands(cli: Group):
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 add_bento_management_commands(cli: Group):
bentoml.container.build(bento.tag, backend=backend)
return bento
return bentos
bento_command = bento_management_commands()
+20 -19
View File
@@ -5,19 +5,19 @@ import importlib.metadata
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 create_bentoml_cli() -> click.Group:
"""
# 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
+72 -73
View File
@@ -6,93 +6,92 @@ import typing as t
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
@click.group(name="cloud", cls=BentoMLCommandGroup)
def cloud_command():
"""BentoCloud Subcommands Groups."""
@cli.group(name="cloud", cls=BentoMLCommandGroup)
def cloud():
"""BentoCloud Subcommands Groups."""
@cloud.command()
@cog.optgroup.group(
"Login", help="Required login options", cls=cog.RequiredAllOptionGroup
@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,
)
@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")
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.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()
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}")
+235 -241
View File
@@ -9,10 +9,24 @@ import tempfile
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.
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
\b
@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,
``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))
)
result = container.build(
bento_tag,
backend=backend,
image_tag=image_tag,
features=features,
**_memoized,
)
@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( # 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
if result is not None:
tags = determine_container_tag(bento_tag, image_tag=image_tag)
``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)
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)
File diff suppressed because it is too large Load Diff
+55 -56
View File
@@ -12,6 +12,10 @@ from gettext import gettext
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
@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}")
@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"
is_windows = sys.platform == "win32"
info_dict: dict[str, str | list[str]] = {
"bentoml": importlib.metadata.version("bentoml"),
"python": platform.python_version(),
"platform": platform.platform(),
}
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
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()}"
# 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"])
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]
# 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
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)
# 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)
+313 -275
View File
@@ -7,16 +7,31 @@ import click
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
@click.group(name="models", cls=BentoMLCommandGroup)
def model_command():
"""Model Subcommands Groups"""
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_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
@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)
\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
)
]
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
)
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,
context=t.cast("SharedOptions", ctx.obj).cloud_context,
query=model_spec.filter,
)
@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,
force=force,
threads=threads,
context=shared_options.cloud_context,
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_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"],
)
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))
)
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_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,
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,
)
+13 -2
View File
@@ -46,13 +46,19 @@ def deprecated_option(*param_decls: str, **attrs: t.Any):
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 add_serve_command(cli: click.Group) -> None:
reload=reload,
development_mode=False,
)
return cli
serve_command = build_serve_command()
+11 -1
View File
@@ -11,10 +11,15 @@ import click
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 add_start_command(cli: click.Group) -> None:
host=host,
backlog=backlog,
)
return cli
start_command = build_start_command()
+106 -97
View File
@@ -17,7 +17,6 @@ from click.exceptions import UsageError
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 @@ if t.TYPE_CHECKING:
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 @@ class BentoMLCommandGroup(click.Group):
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 @@ class BentoMLCommandGroup(click.Group):
@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 @@ class BentoMLCommandGroup(click.Group):
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
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)
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
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