diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 262b47901eb..64403d43cde 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,6 +1,6 @@ # Developer Guide -Before getting started, check out the `#bentoml-contributors` channel in the [BentoML community slack](https://l.linklyhq.com/l/ktOh). +Before getting started, check out the `#bentoml-contributors` channel in the [BentoML community slack](https://l.bentoml.com/join-slack). If you are interested in contributing to existing issues and feature requets, check out the [good-first-issue](https://github.com/bentoml/BentoML/issues?q=is%3Aopen+is%3Aissue+label%3Agood-first-issue) and [help-wanted](https://github.com/bentoml/BentoML/issues?q=is%3Aopen+is%3Aissue+label%3Ahelp-wanted) issues list. diff --git a/README.md b/README.md index b9c329b1fcb..2761e7ddf02 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ for the easiest and fastest way to deploy your bento. - [Examples](https://github.com/bentoml/BentoML/tree/main/examples) - Gallery of sample projects using BentoML - [ML Framework Guides](https://docs.bentoml.org/en/latest/frameworks/index.html) - Best practices and example usages by the ML framework of your choice - [Advanced Guides](https://docs.bentoml.org/en/latest/guides/index.html) - Learn about BentoML's internals, architecture and advanced features -- Need help? [Join BentoML Community Slack πŸ’¬](https://l.linklyhq.com/l/ktOh) +- Need help? [Join BentoML Community Slack πŸ’¬](https://l.bentoml.com/join-slack) --- @@ -140,7 +140,7 @@ For a more detailed user guide, check out the [BentoML Tutorial](https://docs.be ## Community -- For general questions and support, join the [community slack](https://l.linklyhq.com/l/ktOh). +- For general questions and support, join the [community slack](https://l.bentoml.com/join-slack). - To receive release notification, star & watch the BentoML project on [GitHub](https://github.com/bentoml/BentoML). - To report a bug or suggest a feature request, use [GitHub Issues](https://github.com/bentoml/BentoML/issues/new/choose). - To stay informed with community updates, follow the [BentoML Blog](http://modelserving.com) and [@bentomlai](http://twitter.com/bentomlai) on Twitter. @@ -149,7 +149,7 @@ For a more detailed user guide, check out the [BentoML Tutorial](https://docs.be There are many ways to contribute to the project: -- If you have any feedback on the project, share it under the `#bentoml-contributors` channel in the [community slack](https://l.linklyhq.com/l/ktOh). +- If you have any feedback on the project, share it under the `#bentoml-contributors` channel in the [community slack](https://l.bentoml.com/join-slack). - Report issues you're facing and "Thumbs up" on issues and feature requests that are relevant to you. - Investigate bugs and reviewing other developer's pull requests. - Contributing code or documentation to the project by submitting a GitHub pull request. Check out the [Development Guide](https://github.com/bentoml/BentoML/blob/main/DEVELOPMENT.md). diff --git a/docs/source/guides/migration.rst b/docs/source/guides/migration.rst index 794f7fe39f9..a1c1e896fbe 100644 --- a/docs/source/guides/migration.rst +++ b/docs/source/guides/migration.rst @@ -346,4 +346,4 @@ in one place, and enables advanced GitOps and CI/CD workflow. πŸŽ‰Β Ta-da, you have migrated your project to BentoML 1.0.0. Have more questions? -`Join the BentoML Slack community `_. +`Join the BentoML Slack community `_. diff --git a/requirements/tests-requirements.txt b/requirements/tests-requirements.txt index 05bc083fc92..359c75bef98 100644 --- a/requirements/tests-requirements.txt +++ b/requirements/tests-requirements.txt @@ -19,4 +19,4 @@ protobuf<4.0dev grpcio grpcio-health-checking opentelemetry-instrumentation-grpc==0.35b0 -Pillow +Pillow \ No newline at end of file diff --git a/src/bentoml/__init__.py b/src/bentoml/__init__.py index 03d63968f19..70b5ae50919 100644 --- a/src/bentoml/__init__.py +++ b/src/bentoml/__init__.py @@ -12,7 +12,7 @@ To learn more, visit BentoML documentation at: http://docs.bentoml.org To get involved with the development, find us on GitHub: https://github.com/bentoml -And join us in the BentoML slack community: https://l.linklyhq.com/l/ktOh +And join us in the BentoML slack community: https://l.bentoml.com/join-slack """ from typing import TYPE_CHECKING diff --git a/src/bentoml/_internal/bento/bento.py b/src/bentoml/_internal/bento/bento.py index 1de8dda0315..b65aa3ea5ef 100644 --- a/src/bentoml/_internal/bento/bento.py +++ b/src/bentoml/_internal/bento/bento.py @@ -412,6 +412,15 @@ def from_bento_model(cls, bento_model: Model) -> BentoModelInfo: ) +def get_service_import_str(svc: Service | str): + from ..service import Service + + if isinstance(svc, Service): + return svc.get_service_import_origin()[0] + else: + return svc + + @attr.frozen(repr=False) class BentoInfo: # for backward compatibility in case new fields are added to BentoInfo. @@ -420,9 +429,7 @@ class BentoInfo: __omit_if_default__ = True tag: Tag - service: str = attr.field( - converter=lambda svc: svc if isinstance(svc, str) else svc._import_str - ) + service: str = attr.field(converter=get_service_import_str) name: str = attr.field(init=False) version: str = attr.field(init=False) # using factory explicitly instead of default because omit_if_default is enabled for BentoInfo diff --git a/src/bentoml/_internal/service/loader.py b/src/bentoml/_internal/service/loader.py index 681bc2ebafb..cfe12730e61 100644 --- a/src/bentoml/_internal/service/loader.py +++ b/src/bentoml/_internal/service/loader.py @@ -14,7 +14,6 @@ from ..tag import Tag from ..bento import Bento from ..models import ModelStore -from .service import on_import_svc from .service import on_load_bento from ...exceptions import NotFound from ...exceptions import BentoMLException @@ -169,11 +168,8 @@ def recover_standalone_env_change(): instance, Service ), f'import target "{module_name}:{attrs_str}" is not a bentoml.Service instance' - on_import_svc( - svc=instance, - working_dir=working_dir, - import_str=f"{module_name}:{attrs_str}", - ) + # set import_str for retrieving the service import origin + object.__setattr__(instance, "_import_str", f"{module_name}:{attrs_str}") return instance except ImportServiceError: if sys_path_modified and working_dir: diff --git a/src/bentoml/_internal/service/service.py b/src/bentoml/_internal/service/service.py index 2d2aa484d9d..10307e9fbd1 100644 --- a/src/bentoml/_internal/service/service.py +++ b/src/bentoml/_internal/service/service.py @@ -1,6 +1,9 @@ from __future__ import annotations +import os +import sys import typing as t +import inspect import logging import importlib from typing import TYPE_CHECKING @@ -119,6 +122,7 @@ class Service: # Working dir and Import path of the service, set when the service was imported _working_dir: str | None = attr.field(init=False, default=None) _import_str: str | None = attr.field(init=False, default=None) + _caller_module: str | None = attr.field(init=False, default=None) def __reduce__(self): """ @@ -181,7 +185,7 @@ def get_or_pull(bento_tag): else: from bentoml._internal.service.loader import import_service - return (import_service, (self._import_str, self._working_dir)) + return (import_service, self.get_service_import_origin()) def __init__( self, @@ -226,6 +230,54 @@ def __init__( models=[] if models is None else models, ) + # Set import origin info - import_str can not be determined at this stage yet as + # the variable name is only available in module vars after __init__ is returned + # get_service_import_origin below will use the _caller_module for retriving the + # correct import_str for this service + caller_module = inspect.currentframe().f_back.f_globals["__name__"] + object.__setattr__(self, "_caller_module", caller_module) + object.__setattr__(self, "_working_dir", os.getcwd()) + + def get_service_import_origin(self) -> tuple[str, str]: + """ + Returns the module name and working directory of the service + """ + if not self._import_str: + import_module = self._caller_module + if import_module == "__main__": + if hasattr(sys.modules["__main__"], "__file__"): + import_module = sys.modules["__main__"].__file__ + else: + raise BentoMLException( + "Failed to get service import origin, bentoml.Service object defined interactively in console or notebook is not supported" + ) + + if self._caller_module not in sys.modules: + raise BentoMLException( + "Failed to get service import origin, bentoml.Service object must be defined in a module" + ) + + for name, value in vars(sys.modules[self._caller_module]).items(): + if value is self: + object.__setattr__(self, "_import_str", f"{import_module}:{name}") + break + + if not self._import_str: + raise BentoMLException( + "Failed to get service import origin, bentoml.Service object must be assigned to a variable at module level" + ) + + assert self._working_dir is not None + + return self._import_str, self._working_dir + + def is_service_importable(self) -> bool: + if self._caller_module == "__main__": + if not hasattr(sys.modules["__main__"], "__file__"): + return False + + return True + def api( self, input: IODescriptor[t.Any], # pylint: disable=redefined-builtin @@ -247,13 +299,15 @@ def decorator(func: D) -> D: def __str__(self): if self.bento: return f'bentoml.Service(tag="{self.tag}", ' f'path="{self.bento.path}")' - elif self._import_str and self._working_dir: + + try: + import_str, working_dir = self.get_service_import_origin() return ( f'bentoml.Service(name="{self.name}", ' - f'import_str="{self._import_str}", ' - f'working_dir="{self._working_dir}")' + f'import_str="{import_str}", ' + f'working_dir="{working_dir}")' ) - else: + except BentoMLException: return ( f'bentoml.Service(name="{self.name}", ' f'runners=[{",".join([r.name for r in self.runners])}])' @@ -263,16 +317,17 @@ def __repr__(self): return self.__str__() def __eq__(self, other: Service): + if self is other: + return True + if self.bento and other.bento: return self.bento.tag == other.bento.tag - if ( - self._working_dir == other._working_dir - and self._import_str == other._import_str - ): - return True - - return False + try: + if self.get_service_import_origin() == other.get_service_import_origin(): + return True + except BentoMLException: + return False @property def doc(self) -> str: @@ -384,8 +439,3 @@ def add_grpc_handlers(self, handlers: list[grpc.GenericRpcHandler]) -> None: def on_load_bento(svc: Service, bento: Bento): object.__setattr__(svc, "bento", bento) object.__setattr__(svc, "tag", bento.info.tag) - - -def on_import_svc(svc: Service, working_dir: str, import_str: str): - object.__setattr__(svc, "_working_dir", working_dir) - object.__setattr__(svc, "_import_str", import_str) diff --git a/src/bentoml/server.py b/src/bentoml/server.py index ffd4a2a256e..47a9954b6fb 100644 --- a/src/bentoml/server.py +++ b/src/bentoml/server.py @@ -12,8 +12,10 @@ from simple_di import inject from simple_di import Provide +from .exceptions import BentoMLException from ._internal.tag import Tag from ._internal.bento import Bento +from ._internal.service import Service from ._internal.configuration.containers import BentoMLContainer if TYPE_CHECKING: @@ -28,7 +30,7 @@ class Server(ABC): - bento: str | Bento | Tag + servable: str | Bento | Tag | Service host: str port: int @@ -40,7 +42,7 @@ class Server(ABC): def __init__( self, - bento: str | Bento | Tag, + servable: str | Bento | Tag | Service, serve_cmd: str, reload: bool, production: bool, @@ -50,15 +52,37 @@ def __init__( working_dir: str | None, api_workers: int | None, backlog: int, + bento: str | Bento | Tag | Service | None = None, ): - self.bento = bento + if bento is not None: + if not servable: + logger.warning( + "'bento' is deprecated, either remove it as a kwargs or pass '%s' as the first positional argument", + bento, + ) + servable = bento + else: + raise BentoMLException( + "Cannot use both 'bento' and 'servable' as kwargs as 'bento' is deprecated." + ) - if isinstance(bento, Bento): - bento_str = str(bento.tag) - elif isinstance(bento, Tag): - bento_str = str(bento) + self.servable = servable + # backward compatibility + self.bento = servable + + working_dir = None + if isinstance(servable, Bento): + bento_str = str(servable.tag) + elif isinstance(servable, Tag): + bento_str = str(servable) + elif isinstance(servable, Service): + if not servable.is_service_importable(): + raise BentoMLException( + "Cannot use 'bentoml.Service' as a server if it is defined in interactive session or Jupyter Notebooks." + ) + bento_str, working_dir = servable.get_service_import_origin() else: - bento_str = bento + bento_str = servable args: list[str] = [ sys.executable, @@ -74,6 +98,8 @@ def __init__( str(backlog), ] + if working_dir: + args.extend(["--working-dir", working_dir]) if not production: args.append("--development") if reload: @@ -183,7 +209,7 @@ class HTTPServer(Server): @inject def __init__( self, - bento: str | Bento | Tag, + bento: str | Bento | Tag | Service, reload: bool = False, production: bool = True, env: t.Literal["conda"] | None = None, @@ -279,7 +305,7 @@ class GrpcServer(Server): @inject def __init__( self, - bento: str | Bento | Tag, + bento: str | Bento | Tag | Service, reload: bool = False, production: bool = True, env: t.Literal["conda"] | None = None, diff --git a/tests/e2e/bento_server_http/tests/conftest.py b/tests/e2e/bento_server_http/tests/conftest.py index 917de01f935..911e17fc6d5 100644 --- a/tests/e2e/bento_server_http/tests/conftest.py +++ b/tests/e2e/bento_server_http/tests/conftest.py @@ -50,11 +50,13 @@ def fixture_server_config_file(request: FixtureRequest) -> str: @pytest.fixture(autouse=True, scope="package") -def bento_directory(request): +def bento_directory(request: FixtureRequest): bento_path = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) os.chdir(bento_path) + sys.path.insert(0, bento_path) yield os.chdir(request.config.invocation_dir) + sys.path.pop(0) @pytest.fixture(scope="session") diff --git a/tests/e2e/bento_server_http/tests/test_serve.py b/tests/e2e/bento_server_http/tests/test_serve.py index 5888ed4ca9b..07887305c2e 100644 --- a/tests/e2e/bento_server_http/tests/test_serve.py +++ b/tests/e2e/bento_server_http/tests/test_serve.py @@ -62,3 +62,29 @@ def test_http_server_ctx(bentoml_home: str): # the process, it should be negative. # on all other platforms, this should be 0. assert server.process.poll() <= 0 + + +def test_serve_from_svc(): + from service import svc + + server = bentoml.HTTPServer(svc, port=12348) + server.start() + client = server.get_client() + resp = client.health() + assert resp.status == 200 + server.stop() + + timeout = 60 + start_time = time.time() + while time.time() - start_time < timeout: + retcode = server.process.poll() + if retcode is not None and retcode <= 0: + break + if sys.platform == "win32": + # on Windows, because of the way that terminate is run, it seems the exit code is set. + assert isinstance(server.process.poll(), int) + else: + # on POSIX negative return codes mean the process was terminated; since we will be terminating + # the process, it should be negative. + # on all other platforms, this should be 0. + assert server.process.poll() <= 0