Skip to content

Commit

Permalink
components (#1071)
Browse files Browse the repository at this point in the history
* init

* kek

* init

* remove __requires__

* remove pydantic from ABCs

* add str aliases

* use builder

* fix compat

* move handlers

* normalize naming

* make service app better

* fix tests

* fix tests

* fix mypy

* merge

* fix telemetry di
  • Loading branch information
mike0sv authored Apr 24, 2024
1 parent 7d3b8bf commit 35ad596
Show file tree
Hide file tree
Showing 28 changed files with 703 additions and 295 deletions.
3 changes: 3 additions & 0 deletions src/evidently/_pydantic_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pydantic.v1 import UUID4
from pydantic.v1 import BaseConfig
from pydantic.v1 import BaseModel
from pydantic.v1 import Extra
from pydantic.v1 import Field
from pydantic.v1 import PrivateAttr
from pydantic.v1 import SecretStr
Expand All @@ -33,6 +34,7 @@
from pydantic import UUID4
from pydantic import BaseConfig
from pydantic import BaseModel
from pydantic import Extra
from pydantic import Field
from pydantic import PrivateAttr
from pydantic import SecretStr
Expand Down Expand Up @@ -77,4 +79,5 @@
"AbstractSetIntStr",
"DictStrAny",
"PrivateAttr",
"Extra",
]
8 changes: 4 additions & 4 deletions src/evidently/collector/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@
from evidently.collector.storage import LogEvent
from evidently.telemetry import DO_NOT_TRACK_ENV
from evidently.telemetry import event_logger
from evidently.ui.config import NoSecurityConfig
from evidently.ui.components.security import NoSecurityComponent
from evidently.ui.security.no_security import NoSecurityService
from evidently.ui.security.service import SecurityService
from evidently.ui.security.token import TokenSecurity
from evidently.ui.security.token import TokenSecurityConfig
from evidently.ui.security.token import TokenSecurityComponent
from evidently.ui.utils import parse_json

COLLECTOR_INTERFACE = "collector"
Expand Down Expand Up @@ -164,9 +164,9 @@ def create_app(config_path: str = CONFIG_PATH, secret: Optional[str] = None) ->

security: SecurityService
if secret is None:
security = NoSecurityService(NoSecurityConfig())
security = NoSecurityService(NoSecurityComponent())
else:
security = TokenSecurity(TokenSecurityConfig(token=secret))
security = TokenSecurity(TokenSecurityComponent(token=secret))

def auth_middleware_factory(app: ASGIApp) -> ASGIApp:
async def middleware(scope: Scope, receive: Receive, send: Send) -> None:
Expand Down
60 changes: 52 additions & 8 deletions src/evidently/pydantic_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
import itertools
import json
import os
import warnings
from enum import Enum
from functools import lru_cache
from typing import TYPE_CHECKING
from typing import Any
from typing import ClassVar
Expand All @@ -27,7 +29,6 @@

if TYPE_CHECKING:
from evidently._pydantic_compat import DictStrAny
from evidently._pydantic_compat import Model
from evidently.core import IncludeTags
T = TypeVar("T")

Expand Down Expand Up @@ -110,18 +111,56 @@ def all_subclasses(cls: Type[T]) -> Set[Type[T]]:
EVIDENTLY_TYPE_PREFIXES_ENV = "EVIDENTLY_TYPE_PREFIXES"
ALLOWED_TYPE_PREFIXES.extend([p for p in os.environ.get(EVIDENTLY_TYPE_PREFIXES_ENV, "").split(",") if p])

TYPE_ALIASES: Dict[str, Type["PolymorphicModel"]] = {}
TYPE_ALIASES: Dict[Tuple[Type["PolymorphicModel"], str], str] = {}
LOADED_TYPE_ALIASES: Dict[Tuple[Type["PolymorphicModel"], str], Type["PolymorphicModel"]] = {}


def register_type_alias(base_class: Type["PolymorphicModel"], classpath: str, alias: str):
key = (base_class, alias)

if key in TYPE_ALIASES and TYPE_ALIASES[key] != classpath:
warnings.warn(f"Duplicate key {key} in alias map")
TYPE_ALIASES[key] = classpath


def register_loaded_alias(base_class: Type["PolymorphicModel"], cls: Type["PolymorphicModel"], alias: str):
if not issubclass(cls, base_class):
raise ValueError(f"Cannot register alias: {cls.__name__} is not subclass of {base_class.__name__}")

key = (base_class, alias)
if key in TYPE_ALIASES and TYPE_ALIASES[key] != cls:
warnings.warn(f"Duplicate key {key} in alias map")
LOADED_TYPE_ALIASES[key] = cls


@lru_cache()
def get_base_class(cls: Type["PolymorphicModel"]) -> Type["PolymorphicModel"]:
for cls_ in cls.mro():
if not issubclass(cls_, PolymorphicModel):
continue
config = cls_.__dict__.get("Config")
if config is not None and config.__dict__.get("is_base_type", False):
return cls_
return PolymorphicModel


TPM = TypeVar("TPM", bound="PolymorphicModel")


class PolymorphicModel(BaseModel):
class Config:
type_alias: ClassVar[Optional[str]] = None
is_base_type: ClassVar[bool] = False

@classmethod
def __get_type__(cls):
config = cls.__dict__.get("Config")
if config is not None and config.__dict__.get("type_alias") is not None:
return config.type_alias
return cls.__get_classpath__()

@classmethod
def __get_classpath__(cls):
return f"{cls.__module__}.{cls.__name__}"

type: str = Field("")
Expand All @@ -132,21 +171,26 @@ def __init_subclass__(cls):
return
typename = cls.__get_type__()
cls.__fields__["type"].default = typename
TYPE_ALIASES[typename] = cls
register_loaded_alias(get_base_class(cls), cls, typename)

@classmethod
def __subtypes__(cls):
return Union[tuple(all_subclasses(cls))]

@classmethod
def validate(cls: Type["Model"], value: Any) -> "Model":
def validate(cls: Type[TPM], value: Any) -> TPM:
if isinstance(value, dict) and "type" in value:
typename = value.pop("type")
if typename in TYPE_ALIASES:
subcls = TYPE_ALIASES[typename]
key = (get_base_class(cls), typename) # type: ignore[arg-type]
if key in LOADED_TYPE_ALIASES:
subcls = LOADED_TYPE_ALIASES[key]
else:
if not any(typename.startswith(p) for p in ALLOWED_TYPE_PREFIXES):
raise ValueError(f"{typename} does not match any allowed prefixes")
if key in TYPE_ALIASES:
classpath = TYPE_ALIASES[key]
else:
classpath = typename
if not any(classpath.startswith(p) for p in ALLOWED_TYPE_PREFIXES):
raise ValueError(f"{classpath} does not match any allowed prefixes")
subcls = import_string(typename)
return subcls.validate(value)
return super().validate(value) # type: ignore[misc]
Expand Down
147 changes: 15 additions & 132 deletions src/evidently/ui/app.py
Original file line number Diff line number Diff line change
@@ -1,142 +1,24 @@
import os
from functools import partial
from typing import Any
from typing import Optional

import uvicorn
from iterative_telemetry import IterativeTelemetryLogger
from litestar import Litestar
from litestar import Request
from litestar import Response
from litestar import Router
from litestar.connection import ASGIConnection
from litestar.di import Provide
from litestar.handlers import BaseRouteHandler
from litestar.types import ASGIApp
from litestar.types import Receive
from litestar.types import Scope
from litestar.types import Send

import evidently
from evidently.telemetry import DO_NOT_TRACK
from evidently.ui.api.projects import create_projects_api
from evidently.ui.api.service import service_api
from evidently.ui.api.static import assets_router
from evidently.ui.base import AuthManager
from evidently._pydantic_compat import SecretStr
from evidently.ui.components.base import AppBuilder
from evidently.ui.config import Config
from evidently.ui.config import load_config
from evidently.ui.config import settings
from evidently.ui.errors import EvidentlyServiceError
from evidently.ui.errors import NotEnoughPermissions
from evidently.ui.security.config import NoSecurityConfig
from evidently.ui.security.no_security import NoSecurityService
from evidently.ui.security.service import SecurityService
from evidently.ui.security.token import TokenSecurity
from evidently.ui.security.token import TokenSecurityConfig
from evidently.ui.local_service import LocalConfig
from evidently.ui.security.token import TokenSecurityComponent
from evidently.ui.storage.common import EVIDENTLY_SECRET_ENV
from evidently.ui.storage.common import NoopAuthManager
from evidently.ui.storage.local import create_local_project_manager
from evidently.ui.type_aliases import OrgID
from evidently.ui.type_aliases import UserID
from evidently.ui.utils import parse_json


def unicorn_exception_handler(_: Request, exc: EvidentlyServiceError) -> Response:
return exc.to_response()


async def get_user_id() -> UserID:
return UserID("00000000-0000-0000-0000-000000000001")


async def get_org_id() -> Optional[OrgID]:
return None


async def get_event_logger(telemetry_config: Any):
_event_logger = IterativeTelemetryLogger(
telemetry_config.tool_name,
evidently.__version__,
url=telemetry_config.url,
token=telemetry_config.token,
enabled=telemetry_config.enabled and DO_NOT_TRACK is None,
)
yield partial(_event_logger.send_event, telemetry_config.service_name)


def create_project_manager(
path: str,
auth_manager: AuthManager,
autorefresh: bool,
):
return create_local_project_manager(path, autorefresh, auth_manager)


def is_authenticated(connection: ASGIConnection, _: BaseRouteHandler) -> None:
if not connection.scope["auth"]["authenticated"]:
raise NotEnoughPermissions()


def create_app(config: Config, debug: bool = False):
config_security = config.security
security: SecurityService
if isinstance(config_security, NoSecurityConfig):
security = NoSecurityService(config_security)
elif isinstance(config_security, TokenSecurityConfig):
security = TokenSecurity(config_security)

def auth_middleware_factory(app: ASGIApp) -> ASGIApp:
async def middleware(scope: Scope, receive: Receive, send: Send) -> None:
request: Request = Request(scope)
auth = security.authenticate(request)
if auth is None:
scope["auth"] = {
"authenticated": False,
}
else:
scope["auth"] = {
"user_id": auth.id,
"org_id": auth.org_id,
"authenticated": True,
}
await app(scope, receive, send)

return middleware

app = Litestar(
route_handlers=[
Router(
path="/api",
route_handlers=[
create_projects_api(guard=is_authenticated),
service_api(),
],
),
assets_router(),
],
exception_handlers={
EvidentlyServiceError: unicorn_exception_handler,
},
dependencies={
"telemetry_config": Provide(lambda: config.telemetry, sync_to_thread=False),
"project_manager": Provide(
lambda: create_project_manager(
config.storage.path,
NoopAuthManager(),
autorefresh=config.storage.autorefresh,
),
sync_to_thread=False,
use_cache=True,
),
"user_id": get_user_id,
"org_id": get_org_id,
"log_event": get_event_logger,
"parsed_json": Provide(parse_json, sync_to_thread=False),
},
middleware=[auth_middleware_factory],
debug=debug,
)
return app
def create_app(config: Config):
with config.context() as ctx:
builder = AppBuilder(ctx)
ctx.apply(builder)
app = builder.build()
ctx.finalize(app)
return app


def run(config: Config):
Expand All @@ -152,13 +34,14 @@ def run_local(
conf_path: str = None,
):
settings.configure(settings_module=conf_path)
config = load_config(Config)(settings)
config = load_config(LocalConfig, settings)
config.service.host = host
config.service.port = port
config.storage.path = workspace

if secret or os.environ.get(EVIDENTLY_SECRET_ENV):
config.security = TokenSecurityConfig(token=secret or os.environ.get(EVIDENTLY_SECRET_ENV))
secret = secret or os.environ.get(EVIDENTLY_SECRET_ENV)
if secret is not None:
config.security = TokenSecurityComponent(token=SecretStr(secret))
run(config)


Expand Down
20 changes: 10 additions & 10 deletions src/evidently/ui/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
from evidently._pydantic_compat import PrivateAttr
from evidently._pydantic_compat import parse_obj_as
from evidently.model.dashboard import DashboardInfo
from evidently.pydantic_utils import EvidentlyBaseModel
from evidently.renderers.notebook_utils import determine_template
from evidently.suite.base_suite import MetadataValueType
from evidently.suite.base_suite import ReportBase
Expand Down Expand Up @@ -195,7 +194,7 @@ def reload(self, reload_snapshots: bool = False):
self.project_manager.reload_snapshots(self._user_id, self.id)


class MetadataStorage(EvidentlyBaseModel, ABC):
class MetadataStorage(ABC):
@abstractmethod
def add_project(self, project: Project, user: User, team: Team) -> Project:
raise NotImplementedError
Expand Down Expand Up @@ -243,7 +242,7 @@ def reload_snapshots(self, project_id: ProjectID):
raise NotImplementedError


class BlobStorage(EvidentlyBaseModel, ABC):
class BlobStorage(ABC):
@abstractmethod
@contextlib.contextmanager
def open_blob(self, id: BlobID):
Expand All @@ -261,7 +260,7 @@ def put_snapshot(self, project_id: UUID, snapshot: Snapshot) -> BlobID:
return id


class DataStorage(EvidentlyBaseModel, ABC):
class DataStorage(ABC):
@abstractmethod
def extract_points(self, project_id: ProjectID, snapshot: Snapshot):
raise NotImplementedError
Expand Down Expand Up @@ -307,7 +306,7 @@ class ProjectPermission(Enum):
SNAPSHOT_DELETE = "project_snapshot_delete"


class AuthManager(EvidentlyBaseModel):
class AuthManager(ABC):
allow_default_user: bool = True

@abstractmethod
Expand Down Expand Up @@ -418,11 +417,12 @@ def list_team_users(self, user_id: UserID, team_id: TeamID) -> Dict[UserID, bool
return self._list_team_users(team_id)


class ProjectManager(EvidentlyBaseModel):
metadata: MetadataStorage
blob: BlobStorage
data: DataStorage
auth: AuthManager
class ProjectManager:
def __init__(self, metadata: MetadataStorage, blob: BlobStorage, data: DataStorage, auth: AuthManager):
self.metadata: MetadataStorage = metadata
self.blob: BlobStorage = blob
self.data: DataStorage = data
self.auth: AuthManager = auth

def create_project(
self,
Expand Down
5 changes: 5 additions & 0 deletions src/evidently/ui/components/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .local_storage import DataStorageComponent
from .local_storage import InMemoryDataStorage
from .local_storage import MetadataStorageComponent

__all__ = ["InMemoryDataStorage", "DataStorageComponent", "MetadataStorageComponent"]
Loading

0 comments on commit 35ad596

Please sign in to comment.