Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support reading build config and dependencies from pyproject.toml #5042

Merged
merged 1 commit into from
Oct 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 30 additions & 15 deletions src/_bentoml_impl/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions src/bentoml/_internal/bento/bento.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,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} |"

Expand Down Expand Up @@ -318,7 +318,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():
Expand Down
53 changes: 48 additions & 5 deletions src/bentoml/_internal/bento/build_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from sys import version_info

import attr
import cattrs
import fs
import fs.copy
import jinja2
Expand Down Expand Up @@ -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:
Expand Down
24 changes: 9 additions & 15 deletions src/bentoml/_internal/cloud/deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down
47 changes: 24 additions & 23 deletions src/bentoml/_internal/service/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
12 changes: 1 addition & 11 deletions src/bentoml/_internal/utils/circus/watchfilesplugin.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import logging
import os
import typing as t
from pathlib import Path
from threading import Event
Expand Down Expand Up @@ -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
)
Expand Down
25 changes: 18 additions & 7 deletions src/bentoml/bentos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
Loading
Loading