From 4b0555f19a91c79a3722a60c76a60c2860e69d32 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 24 Oct 2024 14:19:46 +0800 Subject: [PATCH] feat: support reading build config and dependencies from pyproject.toml (#5042) * feat: support reading build config and dependencies from pyproject.toml Signed-off-by: Frost Ming --- src/_bentoml_impl/loader.py | 45 ++++++++++------ src/bentoml/_internal/bento/bento.py | 4 +- src/bentoml/_internal/bento/build_config.py | 53 +++++++++++++++++-- src/bentoml/_internal/cloud/deployment.py | 24 ++++----- src/bentoml/_internal/service/loader.py | 47 ++++++++-------- .../utils/circus/watchfilesplugin.py | 12 +---- src/bentoml/bentos.py | 25 ++++++--- src/bentoml_cli/bentos.py | 9 +--- src/bentoml_cli/env_manager.py | 9 ++-- src/bentoml_cli/models.py | 28 ++++++---- .../service_loader/test_service_loader.py | 3 +- 11 files changed, 158 insertions(+), 101 deletions(-) diff --git a/src/_bentoml_impl/loader.py b/src/_bentoml_impl/loader.py index 976ed1db860..2dd6911acd0 100644 --- a/src/_bentoml_impl/loader.py +++ b/src/_bentoml_impl/loader.py @@ -10,14 +10,19 @@ import yaml +from bentoml._internal.bento.bento import BENTO_YAML_FILENAME +from bentoml._internal.bento.bento import DEFAULT_BENTO_BUILD_FILES from bentoml._internal.context import server_context +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + + if t.TYPE_CHECKING: from _bentoml_sdk import Service -BENTO_YAML_FILENAME = "bento.yaml" -BENTO_BUILD_CONFIG_FILENAME = "bentofile.yaml" - def normalize_identifier( service_identifier: str, @@ -51,23 +56,32 @@ def normalize_identifier( # this is a bento directory yaml_path = path.joinpath(BENTO_YAML_FILENAME) bento_path = path.joinpath("src") - elif path.is_file() and path.name == "bentofile.yaml": - # this is a bentofile.yaml file + elif path.is_file() and path.name in DEFAULT_BENTO_BUILD_FILES: + # this is a bento build config file yaml_path = path bento_path = ( pathlib.Path(working_dir) if working_dir is not None else path.parent ) - elif path.is_dir() and path.joinpath("bentofile.yaml").is_file(): + elif path.is_dir() and any( + path.joinpath(filename).is_file() for filename in DEFAULT_BENTO_BUILD_FILES + ): # this is a bento project directory - yaml_path = path.joinpath("bentofile.yaml") - bento_path = ( - pathlib.Path(working_dir) if working_dir is not None else path.parent + yaml_path = next( + path.joinpath(filename) + for filename in DEFAULT_BENTO_BUILD_FILES + if path.joinpath(filename).is_file() ) + bento_path = pathlib.Path(working_dir) if working_dir is not None else path else: raise ValueError(f"found a path but not a bento: {service_identifier}") - with open(yaml_path, "r") as f: - bento_yaml = yaml.safe_load(f) + if yaml_path.name == "pyproject.toml": + with yaml_path.open("rb") as f: + data = tomllib.load(f) + bento_yaml = data.get("tool", {}).get("bentoml", {}).get("build", {}) + else: + with open(yaml_path, "r") as f: + bento_yaml = yaml.safe_load(f) assert "service" in bento_yaml, "service field is required in bento.yaml" return normalize_package(bento_yaml["service"]), bento_path @@ -153,12 +167,13 @@ def import_service( if bento_path.with_name(BENTO_YAML_FILENAME).exists(): bento = Bento.from_path(str(bento_path.parent)) model_aliases = {m.alias: str(m.tag) for m in bento.info.all_models if m.alias} - elif (bentofile := bento_path.joinpath(BENTO_BUILD_CONFIG_FILENAME)).exists(): - with open(bentofile, encoding="utf-8") as f: - build_config = BentoBuildConfig.from_yaml(f) - model_aliases = build_config.model_aliases else: model_aliases = {} + for filename in DEFAULT_BENTO_BUILD_FILES: + if (bentofile := bento_path.joinpath(filename)).exists(): + build_config = BentoBuildConfig.from_file(bentofile) + model_aliases = build_config.model_aliases + break BentoMLContainer.model_aliases.set(model_aliases) try: diff --git a/src/bentoml/_internal/bento/bento.py b/src/bentoml/_internal/bento/bento.py index 8cd76433445..3022cc1fd76 100644 --- a/src/bentoml/_internal/bento/bento.py +++ b/src/bentoml/_internal/bento/bento.py @@ -66,7 +66,7 @@ BENTO_YAML_FILENAME = "bento.yaml" BENTO_PROJECT_DIR_NAME = "src" BENTO_README_FILENAME = "README.md" -DEFAULT_BENTO_BUILD_FILE = "bentofile.yaml" +DEFAULT_BENTO_BUILD_FILES = ("bentofile.yaml", "pyproject.toml") API_INFO_MD = "| POST [`/{api}`](#{link}) | {input} | {output} |" @@ -339,7 +339,7 @@ def append_model(model: BentoModelInfo) -> None: ) bento_fs.makedir(BENTO_PROJECT_DIR_NAME) target_fs = bento_fs.opendir(BENTO_PROJECT_DIR_NAME) - with target_fs.open(DEFAULT_BENTO_BUILD_FILE, "w") as bentofile_yaml: + with target_fs.open("bentofile.yaml", "w") as bentofile_yaml: build_config.to_yaml(bentofile_yaml) for dir_path, _, files in ctx_fs.walk(): diff --git a/src/bentoml/_internal/bento/build_config.py b/src/bentoml/_internal/bento/build_config.py index ce55ea9f8ca..15efd9b2199 100644 --- a/src/bentoml/_internal/bento/build_config.py +++ b/src/bentoml/_internal/bento/build_config.py @@ -10,6 +10,7 @@ from sys import version_info import attr +import cattrs import fs import fs.copy import jinja2 @@ -934,16 +935,58 @@ def from_yaml(cls, stream: t.TextIO) -> BentoBuildConfig: except yaml.YAMLError as exc: logger.error(exc) raise + return cls.load(yaml_content) + @classmethod + def from_pyproject(cls, stream: t.BinaryIO) -> BentoBuildConfig: + if sys.version_info >= (3, 11): + import tomllib + else: + import tomli as tomllib + data = tomllib.load(stream) + config = data.get("tool", {}).get("bentoml", {}).get("build", {}) + if "name" in data.get("project", {}): + config.setdefault("name", data["project"]["name"]) + build_config = cls.load(config) + dependencies = data.get("project", {}).get("dependencies", {}) + python_packages = build_config.python.packages or [] + python_packages.extend(dependencies) + object.__setattr__(build_config.python, "packages", python_packages) + return build_config + + @classmethod + def from_file(cls, path: str) -> BentoBuildConfig: + if os.path.basename(path) == "pyproject.toml": + with open(path, "rb") as f: + return cls.from_pyproject(f) + else: + with open(path, encoding="utf-8") as f: + return cls.from_yaml(f) + + @classmethod + def load(cls, data: dict[str, t.Any]) -> BentoBuildConfig: try: - return bentoml_cattr.structure(yaml_content, cls) - except TypeError as e: - if "missing 1 required positional argument: 'service'" in str(e): + return bentoml_cattr.structure(data, cls) + except cattrs.errors.BaseValidationError as e: + if any( + isinstance(exc, KeyError) and exc.args[0] == "service" + for exc in e.exceptions + ): raise InvalidArgument( 'Missing required build config field "service", which indicates import path of target bentoml.Service instance. e.g.: "service: fraud_detector.py:svc"' - ) from e + ) from None else: - raise InvalidArgument(str(e)) from e + raise + + @classmethod + def from_bento_dir(cls, bento_dir: str) -> BentoBuildConfig: + from .bento import DEFAULT_BENTO_BUILD_FILES + + for filename in DEFAULT_BENTO_BUILD_FILES: + bentofile_path = os.path.join(bento_dir, filename) + if os.path.exists(bentofile_path): + return cls.from_file(bentofile_path).with_defaults() + return cls(service="").with_defaults() def to_yaml(self, stream: t.TextIO) -> None: try: diff --git a/src/bentoml/_internal/cloud/deployment.py b/src/bentoml/_internal/cloud/deployment.py index 47c941b7023..7dd7649cd09 100644 --- a/src/bentoml/_internal/cloud/deployment.py +++ b/src/bentoml/_internal/cloud/deployment.py @@ -21,6 +21,7 @@ from simple_di import Provide from simple_di import inject +from ..bento.bento import DEFAULT_BENTO_BUILD_FILES from ..bento.bento import Bento from ..bento.build_config import BentoBuildConfig from ..configuration import is_editable_bentoml @@ -756,7 +757,7 @@ def _init_deployment_files( else: raise TimeoutError("Timeout waiting for API server pod to be ready") - build_config = get_bento_build_config(bento_dir) + build_config = BentoBuildConfig.from_bento_dir(bento_dir) bento_spec = BentoPathSpec( build_config.include, build_config.exclude, bento_dir ) @@ -768,7 +769,10 @@ def _init_deployment_files( for fn in files: full_path = os.path.join(root, fn) rel_path = os.path.relpath(full_path, bento_dir).replace(os.sep, "/") - if not bento_spec.includes(rel_path) and rel_path != "bentofile.yaml": + if ( + not bento_spec.includes(rel_path) + and rel_path not in DEFAULT_BENTO_BUILD_FILES + ): continue if rel_path in (REQUIREMENTS_TXT, "setup.sh"): continue @@ -813,7 +817,7 @@ def watch(self, bento_dir: str) -> None: from .bento import BentoAPI bento_dir = os.path.abspath(bento_dir) - build_config = get_bento_build_config(bento_dir) + build_config = BentoBuildConfig.from_bento_dir(bento_dir) deployment_api = DeploymentAPI(self._client) bento_api = BentoAPI(self._client) bento_spec = BentoPathSpec( @@ -836,7 +840,7 @@ def watch_filter(change: watchfiles.Change, path: str) -> bool: return EDITABLE_BENTOML_PATHSPEC.match_file(rel_path) rel_path = os.path.relpath(path, bento_dir) return rel_path in ( - "bentofile.yaml", + *DEFAULT_BENTO_BUILD_FILES, REQUIREMENTS_TXT, "setup.sh", ) or bento_spec.includes(rel_path) @@ -925,7 +929,7 @@ def is_bento_changed(bento_info: Bento) -> bool: needs_update = True break - build_config = get_bento_build_config(bento_dir) + build_config = BentoBuildConfig.from_bento_dir(bento_dir) upload_files: list[tuple[str, bytes]] = [] delete_files: list[str] = [] affected_files: set[str] = set() @@ -1436,16 +1440,6 @@ def to_dict(self): return {k: v for k, v in attr.asdict(self).items() if v is not None and v != ""} -def get_bento_build_config(bento_dir: str) -> BentoBuildConfig: - bentofile_path = os.path.join(bento_dir, "bentofile.yaml") - if not os.path.exists(bentofile_path): - return BentoBuildConfig(service="").with_defaults() - else: - # respect bentofile.yaml include and exclude - with open(bentofile_path, "r") as f: - return BentoBuildConfig.from_yaml(f).with_defaults() - - REQUIREMENTS_TXT = "requirements.txt" EDITABLE_BENTOML_DIR = "__editable_bentoml__" EDITABLE_BENTOML_PATHSPEC = PathSpec.from_lines( diff --git a/src/bentoml/_internal/service/loader.py b/src/bentoml/_internal/service/loader.py index 91d16611f75..7e8219deeb4 100644 --- a/src/bentoml/_internal/service/loader.py +++ b/src/bentoml/_internal/service/loader.py @@ -17,7 +17,7 @@ from ..bento import Bento from ..bento.bento import BENTO_PROJECT_DIR_NAME from ..bento.bento import BENTO_YAML_FILENAME -from ..bento.bento import DEFAULT_BENTO_BUILD_FILE +from ..bento.bento import DEFAULT_BENTO_BUILD_FILES from ..bento.build_config import BentoBuildConfig from ..configuration import BENTOML_VERSION from ..configuration.containers import BentoMLContainer @@ -386,33 +386,34 @@ def load( f"Failed loading Bento from directory {bento_path}: {e}" ) logger.info("Service loaded from Bento directory: %s", svc) - elif os.path.isfile( - os.path.expanduser(os.path.join(bento_path, DEFAULT_BENTO_BUILD_FILE)) - ): - # Loading from path to a project directory containing bentofile.yaml + else: try: - with open( - os.path.join(bento_path, DEFAULT_BENTO_BUILD_FILE), - "r", - encoding="utf-8", - ) as f: - build_config = BentoBuildConfig.from_yaml(f) - assert build_config.service, '"service" field in "bentofile.yaml" is required for loading the service, e.g. "service: my_service.py:svc"' - BentoMLContainer.model_aliases.set(build_config.model_aliases) - svc = import_service( - build_config.service, - working_dir=bento_path, - standalone_load=standalone_load, - ) + for filename in DEFAULT_BENTO_BUILD_FILES: + if os.path.isfile( + config_file := os.path.expanduser( + os.path.join(bento_path, filename) + ) + ): + build_config = BentoBuildConfig.from_file(config_file) + BentoMLContainer.model_aliases.set(build_config.model_aliases) + svc = import_service( + build_config.service, + working_dir=bento_path, + standalone_load=standalone_load, + ) + logger.debug( + "'%s' loaded from '%s': %s", svc.name, bento_path, svc + ) + break + else: + raise BentoMLException( + f"Failed loading service from path {bento_path}. When loading from a path, it must be either a Bento " + "containing bento.yaml or a project directory containing bentofile.yaml" + ) except ImportServiceError as e: raise BentoMLException( f"Failed loading Bento from directory {bento_path}: {e}" ) - logger.debug("'%s' loaded from '%s': %s", svc.name, bento_path, svc) - else: - raise BentoMLException( - f"Failed loading service from path {bento_path}. When loading from a path, it must be either a Bento containing bento.yaml or a project directory containing bentofile.yaml" - ) else: try: # Loading from service definition file, e.g. "my_service.py:svc" diff --git a/src/bentoml/_internal/utils/circus/watchfilesplugin.py b/src/bentoml/_internal/utils/circus/watchfilesplugin.py index 6d065dddb9b..323a5dd34ed 100644 --- a/src/bentoml/_internal/utils/circus/watchfilesplugin.py +++ b/src/bentoml/_internal/utils/circus/watchfilesplugin.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -import os import typing as t from pathlib import Path from threading import Event @@ -80,16 +79,7 @@ def __init__(self, *args: t.Any, **config: t.Any): ) def create_spec(self) -> BentoPathSpec: - bentofile_path = os.path.join(self.working_dir, "bentofile.yaml") - if not os.path.exists(bentofile_path): - # if bentofile.yaml is not found, by default we will assume to watch all files - # via BentoBuildConfig.with_defaults() - build_config = BentoBuildConfig(service="").with_defaults() - else: - # respect bentofile.yaml include and exclude - with open(bentofile_path, "r") as f: - build_config = BentoBuildConfig.from_yaml(f).with_defaults() - + build_config = BentoBuildConfig.from_bento_dir(self.working_dir) return BentoPathSpec( build_config.include, build_config.exclude, self.working_dir ) diff --git a/src/bentoml/bentos.py b/src/bentoml/bentos.py index df9e1a00224..0947c5b64f8 100644 --- a/src/bentoml/bentos.py +++ b/src/bentoml/bentos.py @@ -10,6 +10,8 @@ from simple_di import Provide from simple_di import inject +from bentoml._internal.bento.bento import DEFAULT_BENTO_BUILD_FILES + from ._internal.bento import Bento from ._internal.bento.build_config import BentoBuildConfig from ._internal.configuration.containers import BentoMLContainer @@ -372,7 +374,7 @@ def build( @inject def build_bentofile( - bentofile: str = "bentofile.yaml", + bentofile: str | None = None, *, version: str | None = None, labels: dict[str, str] | None = None, @@ -398,13 +400,22 @@ def build_bentofile( Returns: Bento: a Bento instance representing the materialized Bento saved in BentoStore """ - try: - bentofile = resolve_user_filepath(bentofile, build_ctx) - except FileNotFoundError: - raise InvalidArgument(f'bentofile "{bentofile}" not found') + if bentofile: + try: + bentofile = resolve_user_filepath(bentofile, build_ctx) + except FileNotFoundError: + raise InvalidArgument(f'bentofile "{bentofile}" not found') + else: + for filename in DEFAULT_BENTO_BUILD_FILES: + try: + bentofile = resolve_user_filepath(filename, build_ctx) + break + except FileNotFoundError: + pass + else: + raise InvalidArgument("No bentofile found, please provide a bentofile path") - with open(bentofile, "r", encoding="utf-8") as f: - build_config = BentoBuildConfig.from_yaml(f) + build_config = BentoBuildConfig.from_file(bentofile) if labels: if not build_config.labels: diff --git a/src/bentoml_cli/bentos.py b/src/bentoml_cli/bentos.py index 307e7d701ce..c9f1f06d47d 100644 --- a/src/bentoml_cli/bentos.py +++ b/src/bentoml_cli/bentos.py @@ -82,7 +82,6 @@ def parse_delete_targets_argument_callback( def bento_management_commands() -> click.Group: import bentoml from bentoml import Tag - from bentoml._internal.bento.bento import DEFAULT_BENTO_BUILD_FILE from bentoml._internal.configuration import get_quiet_mode from bentoml._internal.configuration.containers import BentoMLContainer from bentoml._internal.utils import human_readable_size @@ -342,11 +341,7 @@ def push( @bentos.command() @click.argument("build_ctx", type=click.Path(), default=".") @click.option( - "-f", - "--bentofile", - type=click.STRING, - default=DEFAULT_BENTO_BUILD_FILE, - help="Path to bentofile. Default to 'bentofile.yaml'", + "-f", "--bentofile", help="Path to bentofile. Default to 'bentofile.yaml'" ) @click.option( "--version", @@ -397,7 +392,7 @@ def push( ) def build( # type: ignore (not accessed) build_ctx: str, - bentofile: str, + bentofile: str | None, version: str | None, labels: tuple[str, ...], output: t.Literal["tag", "default"], diff --git a/src/bentoml_cli/env_manager.py b/src/bentoml_cli/env_manager.py index 3ce7ba8fbaf..1110343dea6 100644 --- a/src/bentoml_cli/env_manager.py +++ b/src/bentoml_cli/env_manager.py @@ -14,7 +14,7 @@ from simple_di import inject from bentoml._internal.bento.bento import BENTO_YAML_FILENAME -from bentoml._internal.bento.bento import DEFAULT_BENTO_BUILD_FILE +from bentoml._internal.bento.bento import DEFAULT_BENTO_BUILD_FILES from bentoml._internal.bento.bento import Bento from bentoml._internal.bento.bento import BentoStore from bentoml._internal.configuration import get_debug_mode @@ -67,14 +67,17 @@ def get_environment( return EnvManager.from_bento( env_type=env, bento=Bento.from_fs(bento_path_fs) ).environment - elif bento_path_fs.isfile(DEFAULT_BENTO_BUILD_FILE): + elif any( + bento_path_fs.isfile(filename) for filename in DEFAULT_BENTO_BUILD_FILES + ): # path to a bento project raise NotImplementedError( "Serving from development project is not yet supported." ) else: raise BentoMLException( - f"EnvManager failed to create an environment from path {bento_path_fs}. When loading from a path, it must be either a Bento or a project directory containing '{DEFAULT_BENTO_BUILD_FILE}'." + f"EnvManager failed to create an environment from path {bento_path_fs}. " + f"When loading from a path, it must be either a Bento or a project directory containing '{DEFAULT_BENTO_BUILD_FILES[0]}'." ) else: try: diff --git a/src/bentoml_cli/models.py b/src/bentoml_cli/models.py index b0de315f089..1d35fa88968 100644 --- a/src/bentoml_cli/models.py +++ b/src/bentoml_cli/models.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import typing as t import click @@ -12,7 +13,7 @@ from simple_di import inject from bentoml import Tag -from bentoml._internal.bento.bento import DEFAULT_BENTO_BUILD_FILE +from bentoml._internal.bento.bento import DEFAULT_BENTO_BUILD_FILES from bentoml._internal.bento.build_config import BentoBuildConfig from bentoml._internal.container import BentoMLContainer from bentoml._internal.utils import calc_dir_size @@ -279,8 +280,6 @@ def import_from(model_path: str) -> None: # type: ignore (not accessed) @click.option( "-F", "--bentofile", - type=click.STRING, - default=DEFAULT_BENTO_BUILD_FILE, help="Path to bentofile. Default to 'bentofile.yaml'", ) @click.pass_context @@ -289,7 +288,7 @@ def pull( ctx: click.Context, model_tag: str | None, force: bool, - bentofile: str, + bentofile: str | None, cloud_client: BentoCloudClient = Provide[BentoMLContainer.bentocloud_client], ): """Pull Model from a remote model store. If model_tag is not provided, @@ -303,13 +302,20 @@ def pull( cloud_client.model.pull(model_tag, force=force) 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 bentofile: + try: + bentofile = resolve_user_filepath(bentofile, None) + except FileNotFoundError: + raise InvalidArgument(f'file "{bentofile}" not found') + else: + for filename in DEFAULT_BENTO_BUILD_FILES: + if os.path.exists(filename): + build_config = BentoBuildConfig.from_file(filename) + break + else: + raise InvalidArgument( + "No bentofile.yaml or pyproject.toml found, please provide a bentofile path" + ) if not build_config.models: raise InvalidArgument( diff --git a/tests/unit/_internal/service_loader/test_service_loader.py b/tests/unit/_internal/service_loader/test_service_loader.py index 2ac50795191..85055082cfa 100644 --- a/tests/unit/_internal/service_loader/test_service_loader.py +++ b/tests/unit/_internal/service_loader/test_service_loader.py @@ -19,8 +19,7 @@ def test_load_service_from_directory(): @pytest.mark.usefixtures("change_test_dir", "model_store") def test_load_service_from_bento(): sys.modules.pop("service", None) - with open("./bento_with_models/bentofile.yaml") as f: - build_config = BentoBuildConfig.from_yaml(f) + build_config = BentoBuildConfig.from_file("./bento_with_models/bentofile.yaml") bento = Bento.create( build_config=build_config, version="1.0", build_ctx="./bento_with_models" ).save()