diff --git a/src/bentoml/_internal/bento/bento.py b/src/bentoml/_internal/bento/bento.py index b3794a79b68..46155983f95 100644 --- a/src/bentoml/_internal/bento/bento.py +++ b/src/bentoml/_internal/bento/bento.py @@ -470,11 +470,23 @@ def append_model(model: BentoModelInfo) -> None: @classmethod def from_path(cls, path: PathType) -> Bento: + path = Path(path) + # Try to infer the tag from the directory structure (store_base/name/version/) + # This provides a fallback for old bento.yaml files that lack name/version fields. + inferred_tag: Tag | None = None + try: + version = path.name + name = path.parent.name + if name and version: + inferred_tag = Tag(name, version) + except (ValueError, TypeError): + pass + try: with open( os.path.join(path, BENTO_YAML_FILENAME), "r", encoding="utf-8" ) as bento_yaml: - info = BaseBentoInfo.from_yaml_file(bento_yaml) + info = BaseBentoInfo.from_yaml_file(bento_yaml, tag=inferred_tag) except FileNotFoundError: raise BentoMLException( f"Failed to load bento because it does not contain a '{BENTO_YAML_FILENAME}'" @@ -797,7 +809,9 @@ def dump(self, stream: t.IO[t.Any]) -> None: return yaml.safe_dump(self.to_dict(), stream, sort_keys=False) @classmethod - def from_yaml_file(cls, stream: t.IO[t.Any]) -> BaseBentoInfo: + def from_yaml_file( + cls, stream: t.IO[t.Any], tag: Tag | None = None + ) -> BaseBentoInfo: try: yaml_content = yaml.safe_load(stream) except yaml.YAMLError as exc: @@ -806,9 +820,27 @@ def from_yaml_file(cls, stream: t.IO[t.Any]) -> BaseBentoInfo: assert yaml_content is not None - yaml_content["tag"] = Tag(yaml_content["name"], yaml_content["version"]) - del yaml_content["name"] - del yaml_content["version"] + try: + yaml_content["tag"] = Tag(yaml_content["name"], yaml_content["version"]) + del yaml_content["name"] + del yaml_content["version"] + except KeyError: + if tag is not None: + yaml_content["tag"] = tag + yaml_content.pop("name", None) + yaml_content.pop("version", None) + else: + missing = [] + if "name" not in yaml_content: + missing.append("name") + if "version" not in yaml_content: + missing.append("version") + raise BentoMLException( + f"Missing required field(s) {', '.join(missing)} in {BENTO_YAML_FILENAME}. " + f"This may be caused by an incompatible bento created with an older version of BentoML. " + f"Consider re-building the bento with the current version of BentoML or " + f"removing the outdated bento from the store." + ) from None # For backwards compatibility for bentos created prior to version 1.0.0rc1 if "runners" in yaml_content: diff --git a/src/bentoml/_internal/container/__init__.py b/src/bentoml/_internal/container/__init__.py index d6091b18c6d..de423589f47 100644 --- a/src/bentoml/_internal/container/__init__.py +++ b/src/bentoml/_internal/container/__init__.py @@ -171,7 +171,7 @@ def construct_containerfile( with tempfile.TemporaryDirectory() as tempdir: with open(bento.path_of("bento.yaml"), "rb") as bento_yaml: - options = BaseBentoInfo.from_yaml_file(bento_yaml) + options = BaseBentoInfo.from_yaml_file(bento_yaml, tag=bento.tag) # tmpdir is our new build context. shutil.copytree(bento.path, tempdir, dirs_exist_ok=True) diff --git a/tests/unit/_internal/bento/test_bento.py b/tests/unit/_internal/bento/test_bento.py index 431c7928f45..e2a662f0ab1 100644 --- a/tests/unit/_internal/bento/test_bento.py +++ b/tests/unit/_internal/bento/test_bento.py @@ -1,6 +1,7 @@ # pylint: disable=unused-argument from __future__ import annotations +import io import os import posixpath from datetime import datetime @@ -374,3 +375,82 @@ def test_build_bento_with_args(): ) BentoMLContainer.bento_arguments.reset() assert bento.info.args == {"label": "awesome"} + + +def test_from_yaml_file_missing_name_and_version_raises(): + """from_yaml_file should raise BentoMLException when name/version are missing + and no fallback tag is provided.""" + from bentoml.exceptions import BentoMLException + + yaml_content = """\ +service: "service:svc" +bentoml_version: "1.1.1" +creation_time: '2024-07-26T17:03:11.523664+00:00' +labels: {} +models: [] +runners: [] +apis: [] +docker: + distro: debian + python_version: '3.11' +python: + requirements_txt: null +conda: + environment_yml: null +""" + stream = io.StringIO(yaml_content) + with pytest.raises(BentoMLException, match="Missing required field"): + BaseBentoInfo.from_yaml_file(stream) + + +def test_from_yaml_file_missing_name_and_version_with_fallback_tag(): + """from_yaml_file should use the fallback tag when name/version are missing.""" + yaml_content = """\ +service: "service:svc" +bentoml_version: "1.1.1" +creation_time: '2024-07-26T17:03:11.523664+00:00' +labels: {} +models: [] +runners: [] +apis: [] +docker: + distro: debian + python_version: '3.11' +python: + requirements_txt: null +conda: + environment_yml: null +""" + fallback_tag = Tag("myservice", "abc123") + stream = io.StringIO(yaml_content) + info = BaseBentoInfo.from_yaml_file(stream, tag=fallback_tag) + assert info.tag == fallback_tag + assert info.name == "myservice" + assert info.version == "abc123" + + +def test_from_yaml_file_missing_only_version_raises(): + """from_yaml_file should raise BentoMLException when version is missing + and no fallback tag is provided.""" + from bentoml.exceptions import BentoMLException + + yaml_content = """\ +service: "service:svc" +name: myservice +bentoml_version: "1.1.1" +creation_time: '2024-07-26T17:03:11.523664+00:00' +labels: {} +models: [] +runners: [] +apis: [] +docker: + distro: debian + python_version: '3.11' +python: + requirements_txt: null +conda: + environment_yml: null +""" + stream = io.StringIO(yaml_content) + with pytest.raises(BentoMLException, match="Missing required field.*version"): + BaseBentoInfo.from_yaml_file(stream)