Skip to content

Commit

Permalink
feat: support reading build config and dependencies from pyproject.to…
Browse files Browse the repository at this point in the history
…ml (#5042)

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

Signed-off-by: Frost Ming <me@frostming.com>
  • Loading branch information
frostming authored Oct 24, 2024
1 parent 59f0bb6 commit 4b0555f
Show file tree
Hide file tree
Showing 11 changed files with 158 additions and 101 deletions.
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 @@ -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} |"

Expand Down Expand Up @@ -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():
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

0 comments on commit 4b0555f

Please sign in to comment.