From 6dcafe836911b8b7f9244ef4389f219123653b50 Mon Sep 17 00:00:00 2001 From: mike0sv Date: Wed, 18 May 2022 13:13:58 +0300 Subject: [PATCH 1/2] redeploy models closes #236 closes #238 --- mlem/api/commands.py | 2 ++ mlem/api/utils.py | 8 +++--- mlem/contrib/heroku/__init__.py | 4 +-- mlem/contrib/heroku/meta.py | 9 ++++--- mlem/core/objects.py | 21 ++++++++++++++++ tests/cli/test_create.py | 4 +-- tests/contrib/test_heroku.py | 43 +++++++++++++++++++++------------ 7 files changed, 63 insertions(+), 28 deletions(-) diff --git a/mlem/api/commands.py b/mlem/api/commands.py index e01168bc..40b30cf5 100644 --- a/mlem/api/commands.py +++ b/mlem/api/commands.py @@ -447,6 +447,8 @@ def deploy( deploy_meta.dump(deploy_meta_or_path, fs, repo, index, external) else: deploy_meta = deploy_meta_or_path + if model is not None: + deploy_meta.replace_model(get_model_meta(model)) # ensuring links are working deploy_meta.get_env() diff --git a/mlem/api/utils.py b/mlem/api/utils.py index 2d26d5b4..1b8e35c4 100644 --- a/mlem/api/utils.py +++ b/mlem/api/utils.py @@ -2,7 +2,7 @@ from typing import Any, Optional, Tuple, Type, TypeVar, Union from mlem.core.base import MlemABC, build_mlem_object -from mlem.core.errors import InvalidArgumentError, WrongMetaType +from mlem.core.errors import InvalidArgumentError from mlem.core.metadata import load, load_meta from mlem.core.objects import MlemDataset, MlemModel, MlemObject @@ -25,15 +25,13 @@ def get_dataset_value(dataset: Any, batch_size: Optional[int] = None) -> Any: return dataset -def get_model_meta(model: Any) -> MlemModel: +def get_model_meta(model: Union[str, MlemModel]) -> MlemModel: if isinstance(model, MlemModel): if model.get_value() is None: model.load_value() return model if isinstance(model, str): - model = load_meta(model) - if not isinstance(model, MlemModel): - raise WrongMetaType(model, MlemModel) + model = load_meta(model, force_type=MlemModel) model.load_value() return model raise InvalidArgumentError( diff --git a/mlem/contrib/heroku/__init__.py b/mlem/contrib/heroku/__init__.py index 3d897d7b..7e5f0b1a 100644 --- a/mlem/contrib/heroku/__init__.py +++ b/mlem/contrib/heroku/__init__.py @@ -1,3 +1,3 @@ -from .meta import HerokuEnvMeta, HerokuState +from .meta import HerokuEnv, HerokuState -__all__ = ["HerokuEnvMeta", "HerokuState"] +__all__ = ["HerokuEnv", "HerokuState"] diff --git a/mlem/contrib/heroku/meta.py b/mlem/contrib/heroku/meta.py index b9d60a32..1750d288 100644 --- a/mlem/contrib/heroku/meta.py +++ b/mlem/contrib/heroku/meta.py @@ -54,7 +54,7 @@ class HerokuDeploy(MlemDeploy): team: Optional[str] = None -class HerokuEnvMeta(MlemEnv[HerokuDeploy]): +class HerokuEnv(MlemEnv[HerokuDeploy]): type: ClassVar = "heroku" deploy_type: ClassVar = HerokuDeploy api_key: Optional[str] = None @@ -72,12 +72,15 @@ def deploy(self, meta: HerokuDeploy): meta.state.app = create_app(meta, api_key=self.api_key) meta.update() - if meta.state.image is None: + redeploy = False + if meta.state.image is None or meta.model_changed(): meta.state.image = build_heroku_docker( meta.get_model(), meta.state.app.name, api_key=self.api_key ) + meta.update_model_hash() meta.update() - if meta.state.release_state is None: + redeploy = True + if meta.state.release_state is None or redeploy: meta.state.release_state = release_docker_app( meta.state.app.name, meta.state.image.image_id, diff --git a/mlem/core/objects.py b/mlem/core/objects.py index a59c199f..4a3e48ec 100644 --- a/mlem/core/objects.py +++ b/mlem/core/objects.py @@ -1,6 +1,7 @@ """ Base classes for meta objects in MLEM """ +import hashlib import os import posixpath import time @@ -341,6 +342,9 @@ def update(self): with no_echo(): self._write_meta(self.location, False) + def meta_hash(self): + return hashlib.md5(safe_dump(self.dict()).encode("utf8")).hexdigest() + class MlemLink(MlemObject): path: str @@ -693,6 +697,8 @@ class Config: abs_name: ClassVar[str] = "deploy_state" + model_hash: Optional[str] = None + @abstractmethod def get_client(self): raise NotImplementedError @@ -824,6 +830,21 @@ def wait_for_status( ) return False + def model_changed(self): + if self.state is None or self.state.model_hash is None: + return True + return self.get_model().meta_hash() != self.state.model_hash + + def update_model_hash(self, model: Optional[MlemModel] = None): + model = model or self.get_model() + if self.state is None: + return + self.state.model_hash = model.meta_hash() + + def replace_model(self, model: MlemModel): + self.model = model + self.model_link = self.model.make_link() + def find_object( path: str, fs: AbstractFileSystem, repo: str = None diff --git a/tests/cli/test_create.py b/tests/cli/test_create.py index 974620cc..fa81890c 100644 --- a/tests/cli/test_create.py +++ b/tests/cli/test_create.py @@ -1,4 +1,4 @@ -from mlem.contrib.heroku.meta import HerokuEnvMeta +from mlem.contrib.heroku.meta import HerokuEnv from mlem.core.metadata import load_meta from mlem.utils.path import make_posix from tests.cli.conftest import Runner @@ -10,5 +10,5 @@ def test_create(runner: Runner, tmp_path): ) assert result.exit_code == 0, result.exception env = load_meta(str(tmp_path)) - assert isinstance(env, HerokuEnvMeta) + assert isinstance(env, HerokuEnv) assert env.api_key == "aaa" diff --git a/tests/contrib/test_heroku.py b/tests/contrib/test_heroku.py index 0ac00de1..34c0b1df 100644 --- a/tests/contrib/test_heroku.py +++ b/tests/contrib/test_heroku.py @@ -17,7 +17,7 @@ from mlem.contrib.heroku.meta import ( HerokuAppMeta, HerokuDeploy, - HerokuEnvMeta, + HerokuEnv, HerokuState, ) from mlem.contrib.heroku.utils import ( @@ -61,9 +61,7 @@ def inner(prefix): @pytest.fixture() def heroku_env(tmpdir_factory): - return HerokuEnvMeta().dump( - str(tmpdir_factory.mktemp("heroku_test") / "env") - ) + return HerokuEnv().dump(str(tmpdir_factory.mktemp("heroku_test") / "env")) @pytest.fixture() @@ -120,18 +118,7 @@ def test_state_ensured_app(): assert state.ensured_app.name == "name" -@heroku -@long -@heroku_matrix -def test_env_deploy_full( - tmp_path_factory, model, heroku_env, heroku_app_name, uses_docker_build -): - name = heroku_app_name("full-cycle") - meta_path = tmp_path_factory.mktemp("deploy-meta") - meta = deploy( - str(meta_path), model, heroku_env, app_name=name, team=HEROKU_TEAM - ) - +def _check_heroku_deployment(meta): assert isinstance(meta, HerokuDeploy) assert heroku_api_request("GET", f"/apps/{meta.state.ensured_app.name}") meta.wait_for_status( @@ -164,6 +151,30 @@ def test_env_deploy_full( assert isinstance(res, list) assert len(res) == 1 + +@heroku +@long +@heroku_matrix +def test_env_deploy_full( + tmp_path_factory, + model: MlemModel, + heroku_env, + heroku_app_name, + uses_docker_build, +): + name = heroku_app_name("full-cycle") + meta_path = tmp_path_factory.mktemp("deploy-meta") + meta = deploy( + str(meta_path), model, heroku_env, app_name=name, team=HEROKU_TEAM + ) + + _check_heroku_deployment(meta) + + model.description = "New version" + model.update() + redeploy_meta = deploy(meta, model, heroku_env) + + _check_heroku_deployment(redeploy_meta) if CLEAR_APPS: meta.destroy() From 4ed752fe2e75ead1ebcce06b2c49781a6cbeb6dc Mon Sep 17 00:00:00 2001 From: mike0sv Date: Wed, 18 May 2022 13:52:53 +0300 Subject: [PATCH 2/2] fix entrypoint --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b0484e2e..e761935c 100644 --- a/setup.py +++ b/setup.py @@ -137,7 +137,7 @@ "docker_registry.docker_io = mlem.contrib.docker.base:DockerIORegistry", "docker_registry.heroku = mlem.contrib.heroku.build:HerokuRemoteRegistry", "docker_registry.remote = mlem.contrib.docker.base:RemoteRegistry", - "env.heroku = mlem.contrib.heroku.meta:HerokuEnvMeta", + "env.heroku = mlem.contrib.heroku.meta:HerokuEnv", "import.pandas = mlem.contrib.pandas:PandasImport", "model_io.catboost_io = mlem.contrib.catboost:CatBoostModelIO", "model_io.lightgbm_io = mlem.contrib.lightgbm:LightGBMModelIO",