Skip to content

Commit

Permalink
✨ ModelResources can be defined with a component or a path
Browse files Browse the repository at this point in the history
  • Loading branch information
perdy committed Jan 19, 2023
1 parent fd61198 commit 1836d48
Show file tree
Hide file tree
Showing 10 changed files with 389 additions and 32 deletions.
40 changes: 37 additions & 3 deletions examples/ml_responsive.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import typing

from sklearn.linear_model import LogisticRegression

from flama import Flama
from flama.models import ModelResource, ModelResourceType
from flama.models import Model, ModelComponent, ModelResource, ModelResourceType
from flama.resources.routing import resource_method

app = Flama()
Expand All @@ -14,12 +18,42 @@


# Adding a Scikit-Learn model:
@app.resources.resource("/sk_model")
@app.models.model("/sk_model")
class MySKLearnModel(ModelResource, metaclass=ModelResourceType):
name = "sk_model"
verbose_name = "SK-Learn Logistic Regression"
model = "path/to/your_model_file.flm"
model_path = "path/to/your_model_file.flm"

@resource_method("/info", methods=["GET"], name="model-info")
def info(self):
return {"name": self.verbose_name}


# Adding a model using a custom component:
model = LogisticRegression()


class CustomModel(Model):
def inspect(self) -> typing.Any:
...

def predict(self, x: typing.Any) -> typing.Any:
...


class CustomModelComponent(ModelComponent):
def resolve(self) -> CustomModel:
return self.model


component = CustomModelComponent(model)


class CustomModelResource(ModelResource, metaclass=ModelResourceType):
name = "custom"
verbose_name = "Custom"
component = component


app.add_component(component)
app.models.add_model_resource(CustomModelResource)
6 changes: 3 additions & 3 deletions flama/models/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
import json
import typing

from flama.components import _ComponentMeta, _BaseComponent
from flama.components import _BaseComponent, _ComponentMeta
from flama.serialize import Format, loads

__all__ = ["Model", "TensorFlowModel", "SKLearnModel", "ModelComponentBuilder"]
__all__ = ["Model", "TensorFlowModel", "SKLearnModel", "ModelComponent", "ModelComponentBuilder"]


class Model:
Expand Down Expand Up @@ -42,7 +42,7 @@ def __init__(self, model):
self.model = model

def get_model_type(self) -> typing.Type[Model]:
return self.model.__class__
return self.model.__class__ # type: ignore[no-any-return]


class ModelComponentBuilder:
Expand Down
21 changes: 16 additions & 5 deletions flama/models/modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,32 @@ def add_model(self, path: str, model: typing.Union[str, os.PathLike], name: str,

class Resource(ModelResource, metaclass=ModelResourceType):
name = name_
model = model_
model_path = model_

resource = Resource()
self.app.resources.add_resource(path, resource) # type: ignore[attr-defined]
self.app.add_component(resource.component) # type: ignore
self.app.add_component(resource.component) # type: ignore[arg-type]

def model(self, path: str, *args, **kwargs) -> typing.Callable:
"""Decorator for Model classes for adding them to the application.
"""Decorator for ModelResource classes for adding them to the application.
:param path: Resource base path.
:return: Decorated resource class.
"""

def decorator(resource: typing.Type["BaseResource"]) -> typing.Type["BaseResource"]:
self.add_model(path, resource, *args, **kwargs)
def decorator(resource: typing.Type[ModelResource]) -> typing.Type[ModelResource]:
self.app.resources.add_resource(path, resource, *args, **kwargs) # type: ignore[attr-defined]
self.app.add_component(resource.component) # type: ignore[arg-type]
return resource

return decorator

def add_model_resource(
self, path: str, resource: typing.Union[ModelResource, typing.Type[ModelResource]], *args, **kwargs
):
"""Adds a resource to this application, setting its endpoints.
:param path: Resource base path.
:param resource: Resource class.
"""
self.app.resources.add_resource(path, resource, *args, **kwargs) # type: ignore[attr-defined]
47 changes: 30 additions & 17 deletions flama/models/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,24 @@

import flama.schemas
from flama.models.components import ModelComponentBuilder
from flama.resources import types, BaseResource
from flama.resources import BaseResource, types
from flama.resources.exceptions import ResourceAttributeError
from flama.resources.resource import ResourceType
from flama.resources.routing import resource_method
import flama.schemas

if typing.TYPE_CHECKING:
from flama.components import Component
from flama.models.components import Model
from flama.models.components import Model, ModelComponent

__all__ = ["ModelResource", "InspectMixin", "PredictMixin", "ModelResourceType"]


class InspectMixin:
@classmethod
def _add_inspect(
mcs, name: str, verbose_name: str, ml_model_type: "Model", **kwargs
mcs, name: str, verbose_name: str, model_model_type: "Model", **kwargs
) -> typing.Dict[str, typing.Any]:
@resource_method("/", methods=["GET"], name=f"{name}-inspect")
async def inspect(self, model: ml_model_type): # type: ignore[valid-type]
async def inspect(self, model: model_model_type): # type: ignore[valid-type]
return model.inspect() # type: ignore[attr-defined]

inspect.__doc__ = f"""
Expand All @@ -44,11 +42,11 @@ async def inspect(self, model: ml_model_type): # type: ignore[valid-type]
class PredictMixin:
@classmethod
def _add_predict(
mcs, name: str, verbose_name: str, ml_model_type: "Model", **kwargs
mcs, name: str, verbose_name: str, model_model_type: "Model", **kwargs
) -> typing.Dict[str, typing.Any]:
@resource_method("/predict/", methods=["POST"], name=f"{name}-predict")
async def predict(
self, model: ml_model_type, data: flama.schemas.schemas.MLModelInput # type: ignore[valid-type]
self, model: model_model_type, data: flama.schemas.schemas.MLModelInput # type: ignore[valid-type]
) -> flama.schemas.schemas.MLModelOutput:
return {"output": model.predict(data["input"])} # type: ignore[attr-defined]

Expand All @@ -69,7 +67,8 @@ async def predict(


class ModelResource(BaseResource):
model: typing.Union[str, os.PathLike]
component: "ModelComponent"
model_path: typing.Union[str, os.PathLike]


class ModelResourceType(ResourceType, InspectMixin, PredictMixin):
Expand All @@ -87,23 +86,37 @@ def __new__(mcs, name: str, bases: typing.Tuple[type], namespace: typing.Dict[st
try:
# Get model component
component = mcs._get_model_component(bases, namespace)
model = component.model # type: ignore[attr-defined]
namespace["component"] = component
namespace["model"] = component.model # type: ignore[attr-defined]
namespace["model"] = component.model
except AttributeError as e:
raise ResourceAttributeError(str(e), name)

metadata_namespace = {"component": component, "model": model, "model_type": type(model)}
metadata_namespace = {
"component": component,
"model": component.model,
"model_type": component.get_model_type(),
}
if "_meta" in namespace:
namespace["_meta"].namespaces["ml"] = metadata_namespace
namespace["_meta"].namespaces["model"] = metadata_namespace
else:
namespace["_meta"] = types.Metadata(namespaces={"ml": metadata_namespace})
namespace["_meta"] = types.Metadata(namespaces={"model": metadata_namespace})

return super().__new__(mcs, name, bases, namespace)

@classmethod
def _get_model_component(
mcs, bases: typing.Sequence[typing.Any], namespace: typing.Dict[str, typing.Any]
) -> "Component":
with open(mcs._get_attribute("model", bases, namespace), "rb") as f:
return ModelComponentBuilder.loads(f.read())
) -> "ModelComponent":
try:
component: "ModelComponent" = mcs._get_attribute("component", bases, namespace)
return component
except AttributeError:
...

try:
with open(mcs._get_attribute("model_path", bases, namespace), "rb") as f:
return ModelComponentBuilder.loads(f.read())
except AttributeError:
...

raise AttributeError(ResourceAttributeError.MODEL_NOT_FOUND)
3 changes: 3 additions & 0 deletions flama/resources/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
class ResourceAttributeError(AttributeError):
ATTRIBUTE_NOT_FOUND = "needs to define attribute '{attribute}'"
# RESTResource
SCHEMA_NOT_FOUND = "needs to define attribute 'schema' or the pair 'input_schema' and 'output_schema'"
RESOURCE_NAME_INVALID = "invalid resource name '{resource_name}'"
PK_NOT_FOUND = "model must define a single-column primary key"
PK_WRONG_TYPE = "model primary key wrong type"
MODEL_INVALID = "model must be a valid SQLAlchemy Table instance or a Model instance"
# ModelResource
MODEL_NOT_FOUND = "needs to define attribute 'model_path' or 'component'"

def __init__(self, msg: str, name: str):
super().__init__(f"{name} {msg}")
1 change: 0 additions & 1 deletion flama/resources/resource.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import dataclasses
import re
import typing

Expand Down
78 changes: 78 additions & 0 deletions tests/models/test_resource.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,86 @@
from unittest.mock import Mock

import pytest
from pytest import param

from flama.models import ModelComponent, ModelResource, ModelResourceType, SKLearnModel, TensorFlowModel


class TestCaseModelResource:
@pytest.fixture(params=["tensorflow", "sklearn"])
def model(self, request):
if request.param == "tensorflow":
return TensorFlowModel(Mock())
elif request.param == "sklearn":
return SKLearnModel(Mock())
else:
raise AttributeError("Wrong lib")

@pytest.fixture
def component(self, model):
class SpecificModelComponent(ModelComponent):
def resolve(self) -> type(model):
return self.model

return SpecificModelComponent(model)

@pytest.fixture
def resource_using_component(self, component):
component_ = component

class PuppyModelResource(ModelResource, metaclass=ModelResourceType):
name = "puppy"
verbose_name = "Puppy"
component = component_

return PuppyModelResource()

@pytest.fixture(params=["tensorflow", "sklearn"])
def resource_using_model_path(self, request):
if request.param == "tensorflow":
model_path_ = "tests/models/tensorflow_model.flm"
elif request.param == "sklearn":
model_path_ = "tests/models/sklearn_model.flm"
else:
raise AttributeError("Wrong lib")

class PuppyModelResource(ModelResource, metaclass=ModelResourceType):
name = "puppy"
verbose_name = "Puppy"
model_path = model_path_

return PuppyModelResource()

def test_resource_using_component(self, resource_using_component, model, component):
assert not hasattr(resource_using_component, "name")
assert not hasattr(resource_using_component, "verbose_name")
assert hasattr(resource_using_component, "component")
assert resource_using_component.component == component
assert hasattr(resource_using_component, "model")
assert resource_using_component.model == model
assert hasattr(resource_using_component, "_meta")
assert resource_using_component._meta.name == "puppy"
assert resource_using_component._meta.verbose_name == "Puppy"
assert resource_using_component._meta.namespaces == {
"model": {"component": component, "model": model, "model_type": component.get_model_type()}
}

def test_resource_using_model_path(self, resource_using_model_path):
assert not hasattr(resource_using_model_path, "name")
assert not hasattr(resource_using_model_path, "verbose_name")
assert hasattr(resource_using_model_path, "component")
component = resource_using_model_path.component
assert hasattr(resource_using_model_path, "model")
assert resource_using_model_path.model == component.model
assert hasattr(resource_using_model_path, "_meta")
assert resource_using_model_path._meta.name == "puppy"
assert resource_using_model_path._meta.verbose_name == "Puppy"
assert resource_using_model_path._meta.namespaces == {
"model": {"component": component, "model": component.model, "model_type": component.get_model_type()}
}


class TestCaseModelResourceMethods:
@pytest.fixture(scope="function", autouse=True)
def add_models(self, app):
app.models.add_model("/tensorflow/", model="tests/models/tensorflow_model.flm", name="tensorflow")
Expand Down
Loading

0 comments on commit 1836d48

Please sign in to comment.