From 430951704d81c2de9be56e21eb6e6348fe610160 Mon Sep 17 00:00:00 2001 From: Mikhail Sveshnikov Date: Mon, 9 Dec 2024 19:33:36 +0400 Subject: [PATCH] move managers (#1328) * move managers * rename * fix lint * managers di * lint * base dependant * oopsie * oopsie * fix dep * merge * fix list users * merge * merge * fix path params * merge * remove get_project_with_team --- src/evidently/_pydantic_compat.py | 3 + src/evidently/ui/api/models.py | 2 +- src/evidently/ui/api/projects.py | 197 +++---- src/evidently/ui/base.py | 522 +------------------ src/evidently/ui/components/local_storage.py | 8 +- src/evidently/ui/components/security.py | 6 +- src/evidently/ui/components/storage.py | 8 +- src/evidently/ui/local_service.py | 8 + src/evidently/ui/managers/__init__.py | 0 src/evidently/ui/managers/auth.py | 353 +++++++++++++ src/evidently/ui/managers/base.py | 65 +++ src/evidently/ui/managers/projects.py | 216 ++++++++ src/evidently/ui/storage/common.py | 12 +- src/evidently/ui/storage/local/__init__.py | 14 +- src/evidently/ui/storage/local/base.py | 6 +- src/evidently/ui/workspace/cloud.py | 34 +- src/evidently/ui/workspace/remote.py | 16 +- src/evidently/ui/workspace/view.py | 2 +- src/evidently/utils/numpy_encoder.py | 4 + src/evidently/utils/sync.py | 13 +- tests/ui/conftest.py | 2 +- tests/ui/test_app.py | 4 +- 22 files changed, 790 insertions(+), 705 deletions(-) create mode 100644 src/evidently/ui/managers/__init__.py create mode 100644 src/evidently/ui/managers/auth.py create mode 100644 src/evidently/ui/managers/base.py create mode 100644 src/evidently/ui/managers/projects.py diff --git a/src/evidently/_pydantic_compat.py b/src/evidently/_pydantic_compat.py index 3cef08bff5..0fb30e0b0d 100644 --- a/src/evidently/_pydantic_compat.py +++ b/src/evidently/_pydantic_compat.py @@ -12,6 +12,7 @@ from pydantic.v1 import PrivateAttr from pydantic.v1 import SecretStr from pydantic.v1 import ValidationError + from pydantic.v1 import create_model from pydantic.v1 import parse_obj_as from pydantic.v1 import validator from pydantic.v1.fields import SHAPE_DICT @@ -37,6 +38,7 @@ from pydantic import PrivateAttr from pydantic import SecretStr # type: ignore[assignment] from pydantic import ValidationError # type: ignore[assignment] + from pydantic import create_model # type: ignore[attr-defined,no-redef] from pydantic import parse_obj_as from pydantic import validator from pydantic.fields import SHAPE_DICT # type: ignore[attr-defined,no-redef] @@ -77,4 +79,5 @@ "DictStrAny", "PrivateAttr", "Extra", + "create_model", ] diff --git a/src/evidently/ui/api/models.py b/src/evidently/ui/api/models.py index 8e8eefdccb..74fd7a255f 100644 --- a/src/evidently/ui/api/models.py +++ b/src/evidently/ui/api/models.py @@ -18,10 +18,10 @@ from evidently.ui.base import EntityType from evidently.ui.base import Org from evidently.ui.base import Project -from evidently.ui.base import Role from evidently.ui.base import SnapshotMetadata from evidently.ui.base import Team from evidently.ui.base import User +from evidently.ui.managers.auth import Role from evidently.ui.type_aliases import ZERO_UUID from evidently.ui.type_aliases import OrgID from evidently.ui.type_aliases import RoleID diff --git a/src/evidently/ui/api/projects.py b/src/evidently/ui/api/projects.py index 3ae7d22718..3ab265a36e 100644 --- a/src/evidently/ui/api/projects.py +++ b/src/evidently/ui/api/projects.py @@ -2,6 +2,7 @@ import json from dataclasses import asdict from typing import Callable +from typing import Dict from typing import List from typing import Optional from typing import Sequence @@ -12,6 +13,7 @@ from litestar import delete from litestar import get from litestar import post +from litestar.di import Provide from litestar.exceptions import HTTPException from litestar.params import Dependency from litestar.params import Parameter @@ -25,10 +27,7 @@ from evidently.ui.api.models import DashboardInfoModel from evidently.ui.api.models import ReportModel from evidently.ui.api.models import TestSuiteModel -from evidently.ui.base import EntityType -from evidently.ui.base import Permission from evidently.ui.base import Project -from evidently.ui.base import ProjectManager from evidently.ui.base import SnapshotMetadata from evidently.ui.dashboards.base import DashboardPanel from evidently.ui.dashboards.reports import DashboardPanelCounter @@ -36,7 +35,7 @@ from evidently.ui.dashboards.reports import DashboardPanelPlot from evidently.ui.dashboards.test_suites import DashboardPanelTestSuite from evidently.ui.dashboards.test_suites import DashboardPanelTestSuiteCounter -from evidently.ui.errors import NotEnoughPermissions +from evidently.ui.managers.projects import ProjectManager from evidently.ui.type_aliases import OrgID from evidently.ui.type_aliases import ProjectID from evidently.ui.type_aliases import SnapshotID @@ -45,16 +44,22 @@ from evidently.utils import NumpyEncoder -@get("/{project_id:uuid}/reports") -async def list_reports( - project_id: Annotated[ProjectID, Parameter(title="id of project")], +async def path_project_dependency( + project_id: ProjectID, project_manager: Annotated[ProjectManager, Dependency(skip_validation=True)], - log_event: Callable, user_id: UserID, -) -> List[ReportModel]: +): project = await project_manager.get_project(user_id, project_id) if project is None: raise HTTPException(status_code=404, detail="project not found") + return project + + +@get("/{project_id:uuid}/reports") +async def list_reports( + project: Annotated[Project, Dependency()], + log_event: Callable, +) -> List[ReportModel]: reports = [ ReportModel.from_snapshot(s) for s in await project.list_snapshots_async(include_test_suites=False) @@ -64,17 +69,27 @@ async def list_reports( return reports +@get("/{project_id:uuid}/test_suites") +async def list_test_suites( + project: Annotated[Project, Dependency()], + log_event: Callable, +) -> List[TestSuiteModel]: + log_event("list_test_suites") + return [ + TestSuiteModel.from_snapshot(s) + for s in await project.list_snapshots_async(include_reports=False) + if not s.is_report + ] + + @get("/{project_id:uuid}/snapshots") async def list_snapshots( - project_id: Annotated[ProjectID, Parameter(title="id of project")], + project: Annotated[Project, Dependency()], project_manager: Annotated[ProjectManager, Dependency(skip_validation=True)], log_event: Callable, user_id: UserID, ) -> List[SnapshotMetadata]: - project = await project_manager.get_project(user_id, project_id) - if project is None: - raise HTTPException(status_code=404, detail="project not found") - snapshots = await project_manager.list_snapshots(user_id, project_id) + snapshots = await project_manager.list_snapshots(user_id, project.id) log_event("list_snapshots", reports_count=len(snapshots)) return snapshots @@ -94,14 +109,9 @@ async def list_projects( @get("/{project_id:uuid}/info") async def get_project_info( - project_id: Annotated[ProjectID, Parameter(title="id of project")], - project_manager: Annotated[ProjectManager, Dependency(skip_validation=True)], + project: Annotated[Project, Dependency()], log_event: Callable, - user_id: UserID, ) -> Project: - project = await project_manager.get_project(user_id, project_id) - if project is None: - raise HTTPException(status_code=404, detail="project not found") log_event("get_project_info") return project @@ -121,79 +131,46 @@ async def search_projects( @post("/{project_id:uuid}/info") async def update_project_info( - project_id: Annotated[ProjectID, Parameter(title="id of project")], + project: Annotated[Project, Dependency()], data: Project, project_manager: Annotated[ProjectManager, Dependency(skip_validation=True)], log_event: Callable, user_id: UserID, ) -> Project: - project = await project_manager.get_project(user_id, project_id) - if project is None: - raise HTTPException(status_code=404, detail="project not found") - if not await project_manager.auth.check_entity_permission( - user_id, EntityType.Project, project.id, Permission.PROJECT_WRITE - ): - raise NotEnoughPermissions() - project.description = data.description - project.name = data.name - project.date_from = data.date_from - project.date_to = data.date_to - project.dashboard = data.dashboard - await project.save_async() + data.id = project.id + await project_manager.update_project(user_id, data) log_event("update_project_info") return project @get("/{project_id:uuid}/reload") async def reload_project_snapshots( - project_id: Annotated[ProjectID, Parameter(title="id of project")], - project_manager: Annotated[ProjectManager, Dependency(skip_validation=True)], + project: Annotated[Project, Dependency()], log_event: Callable, - user_id: UserID, ) -> None: - project = await project_manager.get_project(user_id, project_id) - if project is None: - raise HTTPException(status_code=404, detail="project not found") await project.reload_async(reload_snapshots=True) log_event("reload_project_snapshots") -@get("/{project_id:uuid}/test_suites") -async def list_test_suites( - project_id: Annotated[ProjectID, Parameter(title="id of project")], - project_manager: Annotated[ProjectManager, Dependency(skip_validation=True)], - log_event: Callable, - user_id: UserID, -) -> List[TestSuiteModel]: - project = await project_manager.get_project(user_id, project_id) - if project is None: - raise HTTPException(status_code=404, detail="project not found") - log_event("list_test_suites") - return [ - TestSuiteModel.from_snapshot(s) - for s in await project.list_snapshots_async(include_reports=False) - if not s.is_report - ] +async def path_snapshot_metadata_dependency( + project: Annotated[Project, Dependency()], + snapshot_id: SnapshotID, +): + snapshot = await project.get_snapshot_metadata_async(snapshot_id) + if snapshot is None: + raise HTTPException(status_code=404, detail="Snapshot not found") + return snapshot @get( "/{project_id:uuid}/{snapshot_id:uuid}/graphs_data/{graph_id:str}", ) async def get_snapshot_graph_data( - project_id: Annotated[ProjectID, Parameter(title="id of project")], - snapshot_id: Annotated[SnapshotID, Parameter(title="id of snapshot")], + snapshot_metadata: Annotated[SnapshotMetadata, Dependency()], graph_id: Annotated[str, Parameter(title="id of graph in snapshot")], - project_manager: Annotated[ProjectManager, Dependency(skip_validation=True)], log_event: Callable, - user_id: UserID, ) -> str: - project = await project_manager.get_project(user_id, project_id) - if project is None: - raise HTTPException(status_code=404, detail="Project not found") - snapshot = await project.get_snapshot_metadata_async(snapshot_id) - if snapshot is None: - raise HTTPException(status_code=404, detail="Snapshot not found") - graph = (await snapshot.get_additional_graphs()).get(graph_id) + graph = (await snapshot_metadata.get_additional_graphs()).get(graph_id) if graph is None: raise HTTPException(status_code=404, detail="Graph not found") log_event("get_snapshot_graph_data") @@ -202,29 +179,20 @@ async def get_snapshot_graph_data( @get("/{project_id:uuid}/{snapshot_id:uuid}/download") async def get_snapshot_download( - project_id: Annotated[ProjectID, Parameter(title="id of project")], - snapshot_id: Annotated[SnapshotID, Parameter(title="id of snapshot")], - project_manager: Annotated[ProjectManager, Dependency(skip_validation=True)], + snapshot_metadata: Annotated[SnapshotMetadata, Dependency()], log_event: Callable, - user_id: UserID, report_format: str = "html", ) -> Response: - project = await project_manager.get_project(user_id, project_id) - if project is None: - raise HTTPException(status_code=404, detail="Project not found") - snapshot = await project.get_snapshot_metadata_async(snapshot_id) - if snapshot is None: - raise HTTPException(status_code=404, detail="Snapshot not found") - report = await snapshot.as_report_base() + report = await snapshot_metadata.as_report_base() if report_format == "html": return Response( report.get_html(), - headers={"content-disposition": f"attachment;filename={snapshot_id}.html"}, + headers={"content-disposition": f"attachment;filename={snapshot_metadata.id}.html"}, ) if report_format == "json": return Response( report.json(), - headers={"content-disposition": f"attachment;filename={snapshot_id}.json"}, + headers={"content-disposition": f"attachment;filename={snapshot_metadata.id}.json"}, ) log_event("get_snapshot_download") raise HTTPException(status_code=400, detail=f"Unknown format {report_format}") @@ -232,20 +200,11 @@ async def get_snapshot_download( @get("/{project_id:uuid}/{snapshot_id:uuid}/data") async def get_snapshot_data( - project_id: Annotated[ProjectID, Parameter(title="id of project")], - snapshot_id: Annotated[SnapshotID, Parameter(title="id of snapshot")], - project_manager: Annotated[ProjectManager, Dependency(skip_validation=True)], + snapshot_metadata: Annotated[SnapshotMetadata, Dependency()], log_event: Callable, - user_id: UserID, ) -> str: - project = await project_manager.get_project(user_id, project_id) - if project is None: - raise HTTPException(status_code=404, detail="Project not found") - snapshot_meta = await project.get_snapshot_metadata_async(snapshot_id) - if snapshot_meta is None: - raise HTTPException(status_code=404, detail="Snapshot not found") - info = DashboardInfoModel.from_dashboard_info(await snapshot_meta.get_dashboard_info()) - snapshot = await snapshot_meta.load() + info = DashboardInfoModel.from_dashboard_info(await snapshot_metadata.get_dashboard_info()) + snapshot = await snapshot_metadata.load() log_event( "get_snapshot_data", snapshot_type="report" if snapshot.is_report else "test_suite", @@ -261,39 +220,25 @@ async def get_snapshot_data( @get("/{project_id:uuid}/{snapshot_id:uuid}/metadata") async def get_snapshot_metadata( - project_id: Annotated[ProjectID, Parameter(title="id of project")], - snapshot_id: Annotated[SnapshotID, Parameter(title="id of snapshot")], - project_manager: Annotated[ProjectManager, Dependency(skip_validation=True)], + snapshot_metadata: Annotated[SnapshotMetadata, Dependency()], log_event: Callable, - user_id: UserID, ) -> SnapshotMetadata: - project = await project_manager.get_project(user_id, project_id) - if project is None: - raise HTTPException(status_code=404, detail="Project not found") - snapshot_meta = await project.get_snapshot_metadata_async(snapshot_id) - if snapshot_meta is None: - raise HTTPException(status_code=404, detail="Snapshot not found") log_event( "get_snapshot_metadata", - snapshot_type="report" if snapshot_meta.is_report else "test_suite", - metric_presets=snapshot_meta.metadata.get(METRIC_PRESETS, []), - metric_generators=snapshot_meta.metadata.get(METRIC_GENERATORS, []), - test_presets=snapshot_meta.metadata.get(TEST_PRESETS, []), - test_generators=snapshot_meta.metadata.get(TEST_GENERATORS, []), + snapshot_type="report" if snapshot_metadata.is_report else "test_suite", + metric_presets=snapshot_metadata.metadata.get(METRIC_PRESETS, []), + metric_generators=snapshot_metadata.metadata.get(METRIC_GENERATORS, []), + test_presets=snapshot_metadata.metadata.get(TEST_PRESETS, []), + test_generators=snapshot_metadata.metadata.get(TEST_GENERATORS, []), ) - return snapshot_meta + return snapshot_metadata @get("/{project_id:uuid}/dashboard/panels") async def list_project_dashboard_panels( - project_id: Annotated[ProjectID, Parameter(title="id of project")], - project_manager: Annotated[ProjectManager, Dependency(skip_validation=True)], + project: Annotated[Project, Dependency()], log_event: Callable, - user_id: UserID, ) -> List[DashboardPanel]: - project = await project_manager.get_project(user_id, project_id) - if project is None: - raise HTTPException(status_code=404, detail="Project not found") log_event("list_project_dashboard_panels") return list(project.dashboard.panels) @@ -318,19 +263,14 @@ async def additional_models() -> ( @get("/{project_id:uuid}/dashboard") async def project_dashboard( - project_id: Annotated[ProjectID, Parameter(title="id of project")], + project: Annotated[Project, Dependency()], # TODO: no datetime, as it unable to validate '2023-07-09T02:03' - project_manager: Annotated[ProjectManager, Dependency(skip_validation=True)], log_event: Callable, - user_id: UserID, timestamp_start: Optional[str] = None, timestamp_end: Optional[str] = None, ) -> str: timestamp_start_ = datetime.datetime.fromisoformat(timestamp_start) if timestamp_start else None timestamp_end_ = datetime.datetime.fromisoformat(timestamp_end) if timestamp_end else None - project = await project_manager.get_project(user_id, project_id) - if project is None: - raise HTTPException(status_code=404, detail="Project not found") info = await DashboardInfoModel.from_project_with_time_range( project, @@ -373,28 +313,25 @@ async def delete_project( @post("/{project_id:uuid}/snapshots") async def add_snapshot( - project_id: Annotated[ProjectID, Parameter(title="id of project")], + project: Annotated[Project, Dependency()], parsed_json: Snapshot, project_manager: Annotated[ProjectManager, Dependency(skip_validation=True)], log_event: Callable, user_id: UserID, ) -> None: - if await project_manager.get_project(user_id, project_id) is None: - raise HTTPException(status_code=404, detail="Project not found") - - await project_manager.add_snapshot(user_id, project_id, parsed_json) + await project_manager.add_snapshot(user_id, project.id, parsed_json) log_event("add_snapshot") @delete("/{project_id:uuid}/{snapshot_id:uuid}") async def delete_snapshot( - project_id: Annotated[ProjectID, Parameter(title="id of project")], + project: Annotated[Project, Dependency()], snapshot_id: Annotated[SnapshotID, Parameter(title="id of snapshot")], project_manager: Annotated[ProjectManager, Dependency(skip_validation=True)], log_event: Callable, user_id: UserID, ) -> None: - await project_manager.delete_snapshot(user_id, project_id, snapshot_id) + await project_manager.delete_snapshot(user_id, project.id, snapshot_id) log_event("delete_snapshot") @@ -434,3 +371,9 @@ def create_projects_api(guard: Callable) -> Router: ), ], ) + + +projects_api_dependencies: Dict[str, Provide] = { + "project": Provide(path_project_dependency), + "snapshot_metadata": Provide(path_snapshot_metadata_dependency), +} diff --git a/src/evidently/ui/base.py b/src/evidently/ui/base.py index c15340ec7c..336fc66534 100644 --- a/src/evidently/ui/base.py +++ b/src/evidently/ui/base.py @@ -5,15 +5,14 @@ from abc import abstractmethod from enum import Enum from typing import IO +from typing import TYPE_CHECKING from typing import Any from typing import ClassVar from typing import Dict from typing import Iterator from typing import List -from typing import NamedTuple from typing import Optional from typing import Set -from typing import Tuple from typing import Type from typing import TypeVar from typing import Union @@ -34,11 +33,6 @@ from evidently.ui.dashboards.base import PanelValue from evidently.ui.dashboards.base import ReportFilter from evidently.ui.dashboards.test_suites import TestFilter -from evidently.ui.errors import NotEnoughPermissions -from evidently.ui.errors import OrgNotFound -from evidently.ui.errors import ProjectNotFound -from evidently.ui.errors import TeamNotFound -from evidently.ui.type_aliases import ZERO_UUID from evidently.ui.type_aliases import BlobID from evidently.ui.type_aliases import DataPoints from evidently.ui.type_aliases import DataPointsAsType @@ -46,7 +40,6 @@ from evidently.ui.type_aliases import OrgID from evidently.ui.type_aliases import PointType from evidently.ui.type_aliases import ProjectID -from evidently.ui.type_aliases import RoleID from evidently.ui.type_aliases import SnapshotID from evidently.ui.type_aliases import TeamID from evidently.ui.type_aliases import TestResultPoints @@ -56,6 +49,9 @@ from evidently.utils.dashboard import inline_iframe_html_template from evidently.utils.sync import sync_api +if TYPE_CHECKING: + from evidently.ui.managers.projects import ProjectManager + class BlobMetadata(BaseModel): id: BlobID @@ -222,7 +218,7 @@ async def build_dashboard_info_async( timestamp_start: Optional[datetime.datetime], timestamp_end: Optional[datetime.datetime], ) -> DashboardInfo: - return await self.dashboard.build(self.project_manager.data, self.id, timestamp_start, timestamp_end) + return await self.dashboard.build(self.project_manager.data_storage, self.id, timestamp_start, timestamp_end) async def show_dashboard_async( self, @@ -262,7 +258,7 @@ async def reload_async(self, reload_snapshots: bool = False): reload = sync_api(reload_async) -class MetadataStorage(ABC): +class ProjectMetadataStorage(ABC): @abstractmethod async def add_project( self, project: Project, user: User, team: Optional[Team], org_id: Optional[OrgID] = None @@ -383,509 +379,3 @@ async def load_points_as_type( timestamp_end: Optional[datetime.datetime], ) -> DataPointsAsType[PointType]: raise NotImplementedError - - -class Permission(Enum): - GRANT_ROLE = "all_grant_role" - REVOKE_ROLE = "all_revoke_role" - LIST_USERS = "all_list_users" - - ORG_READ = "org_read" - ORG_WRITE = "org_write" - ORG_CREATE_TEAM = "org_create_team" - ORG_DELETE = "org_delete" - - TEAM_READ = "team_read" - TEAM_WRITE = "team_write" - TEAM_CREATE_PROJECT = "team_create_project" - TEAM_DELETE = "team_delete" - - PROJECT_READ = "project_read" - PROJECT_WRITE = "project_write" - PROJECT_DELETE = "project_delete" - PROJECT_SNAPSHOT_ADD = "project_snapshot_add" - PROJECT_SNAPSHOT_DELETE = "project_snapshot_delete" - - DATASET_READ = "datasets_read" - DATASET_WRITE = "datasets_write" - DATASET_DELETE = "datasets_delete" - - UNKNOWN = "unknown" - - -class Role(BaseModel): - id: RoleID - name: str - entity_type: Optional[EntityType] - permissions: Set[Permission] - - -class DefaultRole(Enum): - OWNER = "owner" - EDITOR = "editor" - VIEWER = "viewer" - DEMO_VIEWER = "demo_viewer" - - -DEFAULT_ROLE_PERMISSIONS: Dict[Tuple[DefaultRole, Optional[EntityType]], Set[Permission]] = { - (DefaultRole.OWNER, None): set(Permission) - {Permission.UNKNOWN}, - (DefaultRole.EDITOR, EntityType.Org): { - Permission.LIST_USERS, - Permission.ORG_READ, - Permission.ORG_CREATE_TEAM, - Permission.TEAM_READ, - Permission.TEAM_WRITE, - Permission.TEAM_CREATE_PROJECT, - Permission.PROJECT_READ, - Permission.PROJECT_WRITE, - Permission.PROJECT_SNAPSHOT_ADD, - Permission.DATASET_READ, - Permission.DATASET_WRITE, - Permission.DATASET_DELETE, - }, - (DefaultRole.EDITOR, EntityType.Team): { - Permission.LIST_USERS, - Permission.TEAM_READ, - Permission.TEAM_WRITE, - Permission.TEAM_CREATE_PROJECT, - Permission.PROJECT_READ, - Permission.PROJECT_WRITE, - Permission.PROJECT_SNAPSHOT_ADD, - Permission.DATASET_READ, - Permission.DATASET_WRITE, - Permission.DATASET_DELETE, - }, - (DefaultRole.EDITOR, EntityType.Project): { - Permission.LIST_USERS, - Permission.PROJECT_READ, - Permission.PROJECT_WRITE, - Permission.PROJECT_SNAPSHOT_ADD, - }, - (DefaultRole.EDITOR, EntityType.Dataset): { - Permission.LIST_USERS, - Permission.DATASET_READ, - Permission.DATASET_WRITE, - Permission.DATASET_DELETE, - }, - (DefaultRole.VIEWER, EntityType.Org): { - Permission.LIST_USERS, - Permission.ORG_READ, - Permission.PROJECT_READ, - }, - (DefaultRole.VIEWER, EntityType.Team): { - Permission.LIST_USERS, - Permission.TEAM_READ, - Permission.PROJECT_READ, - Permission.DATASET_READ, - }, - (DefaultRole.VIEWER, EntityType.Project): { - Permission.LIST_USERS, - Permission.PROJECT_READ, - }, - (DefaultRole.VIEWER, EntityType.Dataset): { - Permission.LIST_USERS, - Permission.DATASET_READ, - }, - (DefaultRole.DEMO_VIEWER, None): {Permission.PROJECT_READ}, -} - - -ENTITY_READ_PERMISSION = { - EntityType.Org: Permission.ORG_READ, - EntityType.Team: Permission.TEAM_READ, - EntityType.Project: Permission.PROJECT_READ, -} - -ENTITY_NOT_FOUND_ERROR = { - EntityType.Org: OrgNotFound, - EntityType.Team: TeamNotFound, - EntityType.Project: ProjectNotFound, -} - - -def get_default_role_permissions( - default_role: DefaultRole, entity_type: Optional[EntityType] -) -> Tuple[Optional[EntityType], Set[Permission]]: - res = DEFAULT_ROLE_PERMISSIONS.get((default_role, entity_type)) - if res is None: - entity_type = None - res = DEFAULT_ROLE_PERMISSIONS.get((default_role, None)) - if res is None: - raise ValueError(f"No default role for ({default_role}, {entity_type}) pair") - return entity_type, res - - -class UserWithRoles(NamedTuple): - user: User - roles: List[Role] - - -class AuthManager(ABC): - allow_default_user: bool = True - - async def refresh_default_roles(self): - for ( - default_role, - entity_type, - ), permissions in DEFAULT_ROLE_PERMISSIONS.items(): - role = await self.get_default_role(default_role, entity_type) - if role.permissions != permissions: - role.permissions = permissions - await self.update_role(role) - - @abstractmethod - async def update_role(self, role: Role): - raise NotImplementedError - - @abstractmethod - async def get_available_project_ids( - self, user_id: UserID, team_id: Optional[TeamID], org_id: Optional[OrgID] - ) -> Optional[Set[ProjectID]]: - raise NotImplementedError - - @abstractmethod - async def check_entity_permission( - self, - user_id: UserID, - entity_type: EntityType, - entity_id: EntityID, - permission: Permission, - ) -> bool: - raise NotImplementedError - - @abstractmethod - async def create_user(self, user_id: UserID, name: Optional[str]) -> User: - raise NotImplementedError - - @abstractmethod - async def get_user(self, user_id: UserID) -> Optional[User]: - raise NotImplementedError - - @abstractmethod - async def get_default_user(self) -> User: - raise NotImplementedError - - async def get_or_create_user(self, user_id: UserID) -> User: - user = await self.get_user(user_id) - if user is None: - user = await self.create_user(user_id, str(user_id)) - return user - - @abstractmethod - async def _create_team(self, author: UserID, team: Team, org_id: OrgID) -> Team: - raise NotImplementedError - - async def create_team(self, author: UserID, team: Team, org_id: OrgID) -> Team: - if not await self.check_entity_permission(author, EntityType.Org, org_id, Permission.ORG_CREATE_TEAM): - raise NotEnoughPermissions() - return await self._create_team(author, team, org_id) - - @abstractmethod - async def get_team(self, team_id: TeamID) -> Optional[Team]: - raise NotImplementedError - - async def get_team_or_error(self, team_id: TeamID) -> Team: - team = await self.get_team(team_id) - if team is None: - raise TeamNotFound() - return team - - @abstractmethod - async def create_org(self, owner: UserID, org: Org): - raise NotImplementedError - - @abstractmethod - async def get_org(self, org_id: OrgID) -> Optional[Org]: - raise NotImplementedError - - async def delete_org(self, user_id: UserID, org_id: OrgID): - if not await self.check_entity_permission(user_id, EntityType.Org, org_id, Permission.ORG_DELETE): - raise NotEnoughPermissions() - await self._delete_org(org_id) - - @abstractmethod - async def _delete_org(self, org_id: OrgID): - raise NotImplementedError - - async def get_org_or_error(self, org_id: OrgID) -> Org: - org = await self.get_org(org_id) - if org is None: - raise OrgNotFound() - return org - - async def get_or_default_user(self, user_id: UserID) -> User: - return await self.get_or_create_user(user_id) if user_id is not None else await self.get_default_user() - - @abstractmethod - async def _delete_team(self, team_id: TeamID): - raise NotImplementedError - - async def delete_team(self, user_id: UserID, team_id: TeamID): - if not await self.check_entity_permission(user_id, EntityType.Team, team_id, Permission.TEAM_DELETE): - raise NotEnoughPermissions() - await self._delete_team(team_id) - - @abstractmethod - async def get_default_role(self, default_role: DefaultRole, entity_type: Optional[EntityType]) -> Role: - raise NotImplementedError - - @abstractmethod - async def _grant_entity_role(self, entity_type: EntityType, entity_id: EntityID, user_id: UserID, role: Role): - raise NotImplementedError - - async def grant_entity_role( - self, - manager: UserID, - entity_type: EntityType, - entity_id: EntityID, - user_id: UserID, - role: Role, - skip_permission_check: bool = False, - ): - if not skip_permission_check and not await self.check_entity_permission( - manager, entity_type, entity_id, Permission.GRANT_ROLE - ): - raise NotEnoughPermissions() - await self._grant_entity_role(entity_type, entity_id, user_id, role) - - @abstractmethod - async def _revoke_entity_role(self, entity_type: EntityType, entity_id: EntityID, user_id: UserID, role: Role): - raise NotImplementedError - - async def revoke_entity_role( - self, - manager: UserID, - entity_type: EntityType, - entity_id: EntityID, - user_id: UserID, - role: Role, - ): - if not await self.check_entity_permission(manager, entity_type, entity_id, Permission.REVOKE_ROLE): - raise NotEnoughPermissions() - if manager == user_id: - raise NotEnoughPermissions() - await self._revoke_entity_role(entity_type, entity_id, user_id, role) - - @abstractmethod - async def _list_entity_users( - self, entity_type: EntityType, entity_id: EntityID, read_permission: Permission - ) -> List[User]: - raise NotImplementedError - - async def list_entity_users(self, user_id: UserID, entity_type: EntityType, entity_id: EntityID): - if not await self.check_entity_permission(user_id, entity_type, entity_id, Permission.LIST_USERS): - raise ENTITY_NOT_FOUND_ERROR[entity_type]() - return await self._list_entity_users(entity_type, entity_id, Permission.LIST_USERS) - - @abstractmethod - async def _list_entity_users_with_roles( - self, entity_type: EntityType, entity_id: EntityID, read_permission: Permission - ) -> List[UserWithRoles]: - raise NotImplementedError - - async def list_entity_users_with_roles(self, user_id: UserID, entity_type: EntityType, entity_id: EntityID): - if not await self.check_entity_permission(user_id, entity_type, entity_id, Permission.LIST_USERS): - raise ENTITY_NOT_FOUND_ERROR[entity_type]() - return await self._list_entity_users_with_roles(entity_type, entity_id, Permission.LIST_USERS) - - @abstractmethod - async def list_user_teams(self, user_id: UserID, org_id: Optional[OrgID]) -> List[Team]: - raise NotImplementedError - - @abstractmethod - async def list_user_orgs(self, user_id: UserID) -> List[Org]: - raise NotImplementedError - - @abstractmethod - async def list_user_entity_permissions( - self, user_id: UserID, entity_type: EntityType, entity_id: EntityID - ) -> Set[Permission]: - raise NotImplementedError - - @abstractmethod - async def list_user_entity_roles( - self, user_id: UserID, entity_type: EntityType, entity_id: EntityID - ) -> List[Tuple[EntityType, EntityID, Role]]: - raise NotImplementedError - - @abstractmethod - async def list_roles(self, entity_type: Optional[EntityType]) -> List[Role]: - raise NotImplementedError - - -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 - - async def create_project( - self, - name: str, - user_id: UserID, - team_id: Optional[TeamID] = None, - description: Optional[str] = None, - org_id: Optional[TeamID] = None, - ) -> Project: - from evidently.ui.dashboards import DashboardConfig - - project = await self.add_project( - Project( - name=name, - description=description, - dashboard=DashboardConfig(name=name, panels=[]), - team_id=team_id, - org_id=org_id, - ), - user_id, - team_id, - org_id, - ) - return project - - async def add_project( - self, project: Project, user_id: UserID, team_id: Optional[TeamID] = None, org_id: Optional[OrgID] = None - ) -> Project: - user = await self.auth.get_or_default_user(user_id) - team = await self.auth.get_team_or_error(team_id) if team_id else None - if team: - if not await self.auth.check_entity_permission( - user.id, EntityType.Team, team.id, Permission.TEAM_CREATE_PROJECT - ): - raise NotEnoughPermissions() - project.team_id = team_id if team_id != ZERO_UUID else None - org_id = team.org_id if team_id else None - project.org_id = org_id - elif org_id: - project.org_id = org_id - team = None - if not await self.auth.check_entity_permission(user.id, EntityType.Org, org_id, Permission.ORG_WRITE): - raise NotEnoughPermissions() - - project.created_at = project.created_at or datetime.datetime.now() - project = (await self.metadata.add_project(project, user, team, org_id)).bind(self, user.id) - await self.auth.grant_entity_role( - user.id, - EntityType.Project, - project.id, - user.id, - await self.auth.get_default_role(DefaultRole.OWNER, EntityType.Project), - skip_permission_check=True, - ) - return project - - async def update_project(self, user_id: UserID, project: Project): - user = await self.auth.get_or_default_user(user_id) - if not await self.auth.check_entity_permission( - user.id, EntityType.Project, project.id, Permission.PROJECT_WRITE - ): - raise ProjectNotFound() - return await self.metadata.update_project(project) - - async def get_project(self, user_id: UserID, project_id: ProjectID) -> Optional[Project]: - user = await self.auth.get_or_default_user(user_id) - if not await self.auth.check_entity_permission( - user.id, EntityType.Project, project_id, Permission.PROJECT_READ - ): - raise ProjectNotFound() - project = await self.metadata.get_project(project_id) - if project is None: - return None - return project.bind(self, user.id) - - async def delete_project(self, user_id: UserID, project_id: ProjectID): - user = await self.auth.get_or_default_user(user_id) - if not await self.auth.check_entity_permission( - user.id, EntityType.Project, project_id, Permission.PROJECT_DELETE - ): - raise ProjectNotFound() - return await self.metadata.delete_project(project_id) - - async def list_projects(self, user_id: UserID, team_id: Optional[TeamID], org_id: Optional[OrgID]) -> List[Project]: - user = await self.auth.get_or_default_user(user_id) - project_ids = await self.auth.get_available_project_ids(user.id, team_id, org_id) - return [p.bind(self, user.id) for p in await self.metadata.list_projects(project_ids)] - - async def add_snapshot(self, user_id: UserID, project_id: ProjectID, snapshot: Snapshot): - user = await self.auth.get_or_default_user(user_id) - if not await self.auth.check_entity_permission( - user.id, EntityType.Project, project_id, Permission.PROJECT_SNAPSHOT_ADD - ): - raise ProjectNotFound() # todo: better exception - blob = await self.blob.put_snapshot(project_id, snapshot) - await self.metadata.add_snapshot(project_id, snapshot, blob) - await self.data.extract_points(project_id, snapshot) - - async def delete_snapshot(self, user_id: UserID, project_id: ProjectID, snapshot_id: SnapshotID): - user = await self.auth.get_or_default_user(user_id) - if not await self.auth.check_entity_permission( - user.id, EntityType.Project, project_id, Permission.PROJECT_SNAPSHOT_DELETE - ): - raise ProjectNotFound() # todo: better exception - # todo - # self.data.remove_points(project_id, snapshot_id) - # self.blob.delete_snapshot(project_id, snapshot_id) - await self.metadata.delete_snapshot(project_id, snapshot_id) - - async def search_project( - self, - user_id: UserID, - project_name: str, - team_id: Optional[TeamID], - org_id: Optional[OrgID], - ) -> List[Project]: - user = await self.auth.get_or_default_user(user_id) - project_ids = await self.auth.get_available_project_ids(user.id, team_id, org_id) - return [p.bind(self, user.id) for p in await self.metadata.search_project(project_name, project_ids)] - - async def list_snapshots( - self, - user_id: UserID, - project_id: ProjectID, - include_reports: bool = True, - include_test_suites: bool = True, - ) -> List[SnapshotMetadata]: - if not await self.auth.check_entity_permission( - user_id, EntityType.Project, project_id, Permission.PROJECT_READ - ): - raise NotEnoughPermissions() - snapshots = await self.metadata.list_snapshots(project_id, include_reports, include_test_suites) - for s in snapshots: - s.project.bind(self, user_id) - return snapshots - - async def load_snapshot( - self, - user_id: UserID, - project_id: ProjectID, - snapshot: Union[SnapshotID, SnapshotMetadata], - ) -> Snapshot: - if isinstance(snapshot, SnapshotID): - snapshot = await self.get_snapshot_metadata(user_id, project_id, snapshot) - with self.blob.open_blob(snapshot.blob.id) as f: - return parse_obj_as(Snapshot, json.load(f)) - - async def get_snapshot_metadata( - self, user_id: UserID, project_id: ProjectID, snapshot_id: SnapshotID - ) -> SnapshotMetadata: - if not await self.auth.check_entity_permission( - user_id, EntityType.Project, project_id, Permission.PROJECT_READ - ): - raise NotEnoughPermissions() - meta = await self.metadata.get_snapshot_metadata(project_id, snapshot_id) - meta.project.bind(self, user_id) - return meta - - async def reload_snapshots(self, user_id: UserID, project_id: ProjectID): - if not await self.auth.check_entity_permission( - user_id, EntityType.Project, project_id, Permission.PROJECT_READ - ): - raise NotEnoughPermissions() - await self.metadata.reload_snapshots(project_id) diff --git a/src/evidently/ui/components/local_storage.py b/src/evidently/ui/components/local_storage.py index f60f8c0eee..a2d51c4ef4 100644 --- a/src/evidently/ui/components/local_storage.py +++ b/src/evidently/ui/components/local_storage.py @@ -4,14 +4,14 @@ from evidently.ui.base import BlobStorage from evidently.ui.base import DataStorage -from evidently.ui.base import MetadataStorage +from evidently.ui.base import ProjectMetadataStorage from evidently.ui.components.base import FactoryComponent from evidently.ui.components.storage import BlobStorageComponent from evidently.ui.components.storage import DataStorageComponent from evidently.ui.components.storage import MetadataStorageComponent from evidently.ui.storage.local import FSSpecBlobStorage from evidently.ui.storage.local import InMemoryDataStorage -from evidently.ui.storage.local import JsonFileMetadataStorage +from evidently.ui.storage.local import JsonFileProjectMetadataStorage from evidently.ui.storage.local import LocalState @@ -31,9 +31,9 @@ class Config: path: str - def dependency_factory(self) -> Callable[..., MetadataStorage]: + def dependency_factory(self) -> Callable[..., ProjectMetadataStorage]: def json_meta(local_state: Optional[LocalState] = None): - return JsonFileMetadataStorage(path=self.path, local_state=local_state) + return JsonFileProjectMetadataStorage(path=self.path, local_state=local_state) return json_meta diff --git a/src/evidently/ui/components/security.py b/src/evidently/ui/components/security.py index 5ca5cfa6c2..54b67b9eca 100644 --- a/src/evidently/ui/components/security.py +++ b/src/evidently/ui/components/security.py @@ -80,9 +80,9 @@ class SimpleSecurity(SecurityComponent): def get_dependencies(self, ctx: ComponentContext) -> Dict[str, Provide]: return { "user_id": Provide(get_user_id), - "security": Provide(self.get_security, sync_to_thread=False), - "security_config": Provide(lambda: self, sync_to_thread=False), - "auth_manager": Provide(lambda: NoopAuthManager(), sync_to_thread=False), + "security": Provide(self.get_security, sync_to_thread=False, use_cache=True), + "security_config": Provide(lambda: self, sync_to_thread=False, use_cache=True), + "auth_manager": Provide(lambda: NoopAuthManager(), sync_to_thread=False, use_cache=True), } diff --git a/src/evidently/ui/components/storage.py b/src/evidently/ui/components/storage.py index a94676e1e2..1ca642bc4f 100644 --- a/src/evidently/ui/components/storage.py +++ b/src/evidently/ui/components/storage.py @@ -5,9 +5,9 @@ from evidently.pydantic_utils import register_type_alias from evidently.ui.base import BlobStorage from evidently.ui.base import DataStorage -from evidently.ui.base import MetadataStorage -from evidently.ui.base import ProjectManager +from evidently.ui.base import ProjectMetadataStorage from evidently.ui.components.base import FactoryComponent +from evidently.ui.managers.projects import ProjectManager from evidently.ui.storage.common import NoopAuthManager from evidently.ui.storage.local import create_local_project_manager @@ -29,12 +29,12 @@ def dependency_factory(self) -> Callable[..., ProjectManager]: return lambda: create_local_project_manager(self.path, autorefresh=self.autorefresh, auth=NoopAuthManager()) -class MetadataStorageComponent(FactoryComponent[MetadataStorage], ABC): +class MetadataStorageComponent(FactoryComponent[ProjectMetadataStorage], ABC): class Config: is_base_type = True __section__: ClassVar = "metadata" - dependency_name: ClassVar = "metadata_storage" + dependency_name: ClassVar = "project_metadata" use_cache: ClassVar[bool] = True sync_to_thread: ClassVar[bool] = False diff --git a/src/evidently/ui/local_service.py b/src/evidently/ui/local_service.py index 2cdd02519b..37264efec4 100644 --- a/src/evidently/ui/local_service.py +++ b/src/evidently/ui/local_service.py @@ -1,11 +1,14 @@ import logging import time +from typing import Dict from litestar import Request from litestar import Response +from litestar.di import Provide from litestar.logging import LoggingConfig from evidently.ui.api.projects import create_projects_api +from evidently.ui.api.projects import projects_api_dependencies from evidently.ui.api.service import service_api from evidently.ui.api.static import assets_router from evidently.ui.components.base import AppBuilder @@ -32,6 +35,11 @@ def get_api_route_handlers(self, ctx: ComponentContext): guard = ctx.get_component(SecurityComponent).get_auth_guard() return [create_projects_api(guard), service_api()] + def get_dependencies(self, ctx: ComponentContext) -> Dict[str, Provide]: + deps = super().get_dependencies(ctx) + deps.update(projects_api_dependencies) + return deps + def get_route_handlers(self, ctx: ComponentContext): return [assets_router()] diff --git a/src/evidently/ui/managers/__init__.py b/src/evidently/ui/managers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/evidently/ui/managers/auth.py b/src/evidently/ui/managers/auth.py new file mode 100644 index 0000000000..5ffa9079e2 --- /dev/null +++ b/src/evidently/ui/managers/auth.py @@ -0,0 +1,353 @@ +from abc import ABC +from abc import abstractmethod +from enum import Enum +from typing import Dict +from typing import List +from typing import NamedTuple +from typing import Optional +from typing import Set +from typing import Tuple + +from evidently._pydantic_compat import BaseModel +from evidently.ui.base import EntityType +from evidently.ui.base import Org +from evidently.ui.base import Team +from evidently.ui.base import User +from evidently.ui.errors import NotEnoughPermissions +from evidently.ui.errors import OrgNotFound +from evidently.ui.errors import ProjectNotFound +from evidently.ui.errors import TeamNotFound +from evidently.ui.type_aliases import EntityID +from evidently.ui.type_aliases import OrgID +from evidently.ui.type_aliases import ProjectID +from evidently.ui.type_aliases import RoleID +from evidently.ui.type_aliases import TeamID +from evidently.ui.type_aliases import UserID + + +class Permission(Enum): + GRANT_ROLE = "all_grant_role" + REVOKE_ROLE = "all_revoke_role" + LIST_USERS = "all_list_users" + + ORG_READ = "org_read" + ORG_WRITE = "org_write" + ORG_CREATE_TEAM = "org_create_team" + ORG_DELETE = "org_delete" + + TEAM_READ = "team_read" + TEAM_WRITE = "team_write" + TEAM_CREATE_PROJECT = "team_create_project" + TEAM_DELETE = "team_delete" + + PROJECT_READ = "project_read" + PROJECT_WRITE = "project_write" + PROJECT_DELETE = "project_delete" + PROJECT_SNAPSHOT_ADD = "project_snapshot_add" + PROJECT_SNAPSHOT_DELETE = "project_snapshot_delete" + + DATASET_READ = "datasets_read" + DATASET_WRITE = "datasets_write" + DATASET_DELETE = "datasets_delete" + + UNKNOWN = "unknown" + + +class Role(BaseModel): + id: RoleID + name: str + entity_type: Optional[EntityType] + permissions: Set[Permission] + + +class DefaultRole(Enum): + OWNER = "owner" + EDITOR = "editor" + VIEWER = "viewer" + DEMO_VIEWER = "demo_viewer" + + +DEFAULT_ROLE_PERMISSIONS: Dict[Tuple[DefaultRole, Optional[EntityType]], Set[Permission]] = { + (DefaultRole.OWNER, None): set(Permission) - {Permission.UNKNOWN}, + (DefaultRole.EDITOR, EntityType.Org): { + Permission.LIST_USERS, + Permission.ORG_READ, + Permission.ORG_CREATE_TEAM, + Permission.TEAM_READ, + Permission.TEAM_WRITE, + Permission.TEAM_CREATE_PROJECT, + Permission.PROJECT_READ, + Permission.PROJECT_WRITE, + Permission.PROJECT_SNAPSHOT_ADD, + Permission.DATASET_READ, + Permission.DATASET_WRITE, + Permission.DATASET_DELETE, + }, + (DefaultRole.EDITOR, EntityType.Team): { + Permission.LIST_USERS, + Permission.TEAM_READ, + Permission.TEAM_WRITE, + Permission.TEAM_CREATE_PROJECT, + Permission.PROJECT_READ, + Permission.PROJECT_WRITE, + Permission.PROJECT_SNAPSHOT_ADD, + Permission.DATASET_READ, + Permission.DATASET_WRITE, + Permission.DATASET_DELETE, + }, + (DefaultRole.EDITOR, EntityType.Project): { + Permission.LIST_USERS, + Permission.PROJECT_READ, + Permission.PROJECT_WRITE, + Permission.PROJECT_SNAPSHOT_ADD, + }, + (DefaultRole.EDITOR, EntityType.Dataset): { + Permission.LIST_USERS, + Permission.DATASET_READ, + Permission.DATASET_WRITE, + Permission.DATASET_DELETE, + }, + (DefaultRole.VIEWER, EntityType.Org): { + Permission.LIST_USERS, + Permission.ORG_READ, + Permission.PROJECT_READ, + }, + (DefaultRole.VIEWER, EntityType.Team): { + Permission.LIST_USERS, + Permission.TEAM_READ, + Permission.PROJECT_READ, + Permission.DATASET_READ, + }, + (DefaultRole.VIEWER, EntityType.Project): { + Permission.LIST_USERS, + Permission.PROJECT_READ, + }, + (DefaultRole.VIEWER, EntityType.Dataset): { + Permission.LIST_USERS, + Permission.DATASET_READ, + }, + (DefaultRole.DEMO_VIEWER, None): {Permission.PROJECT_READ}, +} + + +ENTITY_READ_PERMISSION = { + EntityType.Org: Permission.ORG_READ, + EntityType.Team: Permission.TEAM_READ, + EntityType.Project: Permission.PROJECT_READ, +} + +ENTITY_NOT_FOUND_ERROR = { + EntityType.Org: OrgNotFound, + EntityType.Team: TeamNotFound, + EntityType.Project: ProjectNotFound, +} + + +def get_default_role_permissions( + default_role: DefaultRole, entity_type: Optional[EntityType] +) -> Tuple[Optional[EntityType], Set[Permission]]: + res = DEFAULT_ROLE_PERMISSIONS.get((default_role, entity_type)) + if res is None: + entity_type = None + res = DEFAULT_ROLE_PERMISSIONS.get((default_role, None)) + if res is None: + raise ValueError(f"No default role for ({default_role}, {entity_type}) pair") + return entity_type, res + + +class UserWithRoles(NamedTuple): + user: User + roles: List[Role] + + +class AuthManager(ABC): + allow_default_user: bool = True + + async def refresh_default_roles(self): + for ( + default_role, + entity_type, + ), permissions in DEFAULT_ROLE_PERMISSIONS.items(): + role = await self.get_default_role(default_role, entity_type) + if role.permissions != permissions: + role.permissions = permissions + await self.update_role(role) + + @abstractmethod + async def update_role(self, role: Role): + raise NotImplementedError + + @abstractmethod + async def get_available_project_ids( + self, user_id: UserID, team_id: Optional[TeamID], org_id: Optional[OrgID] + ) -> Optional[Set[ProjectID]]: + raise NotImplementedError + + @abstractmethod + async def check_entity_permission( + self, + user_id: UserID, + entity_type: EntityType, + entity_id: EntityID, + permission: Permission, + ) -> bool: + raise NotImplementedError + + @abstractmethod + async def create_user(self, user_id: UserID, name: Optional[str]) -> User: + raise NotImplementedError + + @abstractmethod + async def get_user(self, user_id: UserID) -> Optional[User]: + raise NotImplementedError + + @abstractmethod + async def get_default_user(self) -> User: + raise NotImplementedError + + async def get_or_create_user(self, user_id: UserID) -> User: + user = await self.get_user(user_id) + if user is None: + user = await self.create_user(user_id, str(user_id)) + return user + + @abstractmethod + async def _create_team(self, author: UserID, team: Team, org_id: OrgID) -> Team: + raise NotImplementedError + + async def create_team(self, author: UserID, team: Team, org_id: OrgID) -> Team: + if not await self.check_entity_permission(author, EntityType.Org, org_id, Permission.ORG_CREATE_TEAM): + raise NotEnoughPermissions() + return await self._create_team(author, team, org_id) + + @abstractmethod + async def get_team(self, team_id: TeamID) -> Optional[Team]: + raise NotImplementedError + + async def get_team_or_error(self, team_id: TeamID) -> Team: + team = await self.get_team(team_id) + if team is None: + raise TeamNotFound() + return team + + @abstractmethod + async def create_org(self, owner: UserID, org: Org): + raise NotImplementedError + + @abstractmethod + async def get_org(self, org_id: OrgID) -> Optional[Org]: + raise NotImplementedError + + async def delete_org(self, user_id: UserID, org_id: OrgID): + if not await self.check_entity_permission(user_id, EntityType.Org, org_id, Permission.ORG_DELETE): + raise NotEnoughPermissions() + await self._delete_org(org_id) + + @abstractmethod + async def _delete_org(self, org_id: OrgID): + raise NotImplementedError + + async def get_org_or_error(self, org_id: OrgID) -> Org: + org = await self.get_org(org_id) + if org is None: + raise OrgNotFound() + return org + + async def get_or_default_user(self, user_id: UserID) -> User: + return await self.get_or_create_user(user_id) if user_id is not None else await self.get_default_user() + + @abstractmethod + async def _delete_team(self, team_id: TeamID): + raise NotImplementedError + + async def delete_team(self, user_id: UserID, team_id: TeamID): + if not await self.check_entity_permission(user_id, EntityType.Team, team_id, Permission.TEAM_DELETE): + raise NotEnoughPermissions() + await self._delete_team(team_id) + + @abstractmethod + async def get_default_role(self, default_role: DefaultRole, entity_type: Optional[EntityType]) -> Role: + raise NotImplementedError + + @abstractmethod + async def _grant_entity_role(self, entity_type: EntityType, entity_id: EntityID, user_id: UserID, role: Role): + raise NotImplementedError + + async def grant_entity_role( + self, + manager: UserID, + entity_type: EntityType, + entity_id: EntityID, + user_id: UserID, + role: Role, + skip_permission_check: bool = False, + ): + if not skip_permission_check and not await self.check_entity_permission( + manager, entity_type, entity_id, Permission.GRANT_ROLE + ): + raise NotEnoughPermissions() + await self._grant_entity_role(entity_type, entity_id, user_id, role) + + @abstractmethod + async def _revoke_entity_role(self, entity_type: EntityType, entity_id: EntityID, user_id: UserID, role: Role): + raise NotImplementedError + + async def revoke_entity_role( + self, + manager: UserID, + entity_type: EntityType, + entity_id: EntityID, + user_id: UserID, + role: Role, + ): + if not await self.check_entity_permission(manager, entity_type, entity_id, Permission.REVOKE_ROLE): + raise NotEnoughPermissions() + if manager == user_id: + raise NotEnoughPermissions() + await self._revoke_entity_role(entity_type, entity_id, user_id, role) + + @abstractmethod + async def _list_entity_users( + self, entity_type: EntityType, entity_id: EntityID, read_permission: Permission + ) -> List[User]: + raise NotImplementedError + + async def list_entity_users(self, user_id: UserID, entity_type: EntityType, entity_id: EntityID): + if not await self.check_entity_permission(user_id, entity_type, entity_id, Permission.LIST_USERS): + raise ENTITY_NOT_FOUND_ERROR[entity_type]() + return await self._list_entity_users(entity_type, entity_id, Permission.LIST_USERS) + + @abstractmethod + async def _list_entity_users_with_roles( + self, entity_type: EntityType, entity_id: EntityID, read_permission: Permission + ) -> List[UserWithRoles]: + raise NotImplementedError + + async def list_entity_users_with_roles(self, user_id: UserID, entity_type: EntityType, entity_id: EntityID): + if not await self.check_entity_permission(user_id, entity_type, entity_id, Permission.LIST_USERS): + raise ENTITY_NOT_FOUND_ERROR[entity_type]() + return await self._list_entity_users_with_roles(entity_type, entity_id, Permission.LIST_USERS) + + @abstractmethod + async def list_user_teams(self, user_id: UserID, org_id: Optional[OrgID]) -> List[Team]: + raise NotImplementedError + + @abstractmethod + async def list_user_orgs(self, user_id: UserID) -> List[Org]: + raise NotImplementedError + + @abstractmethod + async def list_user_entity_permissions( + self, user_id: UserID, entity_type: EntityType, entity_id: EntityID + ) -> Set[Permission]: + raise NotImplementedError + + @abstractmethod + async def list_user_entity_roles( + self, user_id: UserID, entity_type: EntityType, entity_id: EntityID + ) -> List[Tuple[EntityType, EntityID, Role]]: + raise NotImplementedError + + @abstractmethod + async def list_roles(self, entity_type: Optional[EntityType]) -> List[Role]: + raise NotImplementedError diff --git a/src/evidently/ui/managers/base.py b/src/evidently/ui/managers/base.py new file mode 100644 index 0000000000..088024dd35 --- /dev/null +++ b/src/evidently/ui/managers/base.py @@ -0,0 +1,65 @@ +from inspect import Parameter +from inspect import Signature +from typing import Any +from typing import Dict +from typing import Type + +from litestar.params import Dependency +from typing_extensions import Annotated +from typing_inspect import is_classvar + + +def replace_signature(annotations: Dict[str, Any], return_annotation=..., is_method=False): + """Decorator to trick Litestar DI into providing arguments needed""" + + def dec(f): + parameters = [Parameter(n, kind=Parameter.POSITIONAL_OR_KEYWORD, annotation=t) for n, t in annotations.items()] + if is_method: + parameters.insert(0, Parameter("self", kind=Parameter.POSITIONAL_OR_KEYWORD)) + f.__signature__ = Signature(parameters=parameters, return_annotation=return_annotation) + f.__annotations__ = annotations + f.__annotations__["return"] = return_annotation + return f + + return dec + + +class ProviderGetter: + def __get__(self, instance, owner: Type["BaseDependant"]): + deps = _get_manager_deps(owner) + + @replace_signature({name: Annotated[cls, Dependency(skip_validation=True)] for name, cls in deps.items()}, None) + async def provide(**kwargs): + obj = owner(**kwargs) + await obj.post_provide() + return obj + + provide.__name__ = f"{owner.__name__}.provide" + return provide + + +class BaseDependant: + """Base class that allows to define dependencies as class fields""" + + provide = ProviderGetter() + + def __init__(self, **dependencies): + for k, v in dependencies.items(): + setattr(self, k, v) + + async def post_provide(self): + pass + + +def _get_manager_deps(dependant_type: Type[BaseDependant]) -> Dict[str, Type]: + return { + name: cls + for bt in dependant_type.mro() + if issubclass(bt, BaseDependant) + for name, cls in bt.__annotations__.items() + if not is_classvar(cls) + } + + +class BaseManager(BaseDependant): + pass diff --git a/src/evidently/ui/managers/projects.py b/src/evidently/ui/managers/projects.py new file mode 100644 index 0000000000..2d4e9c4817 --- /dev/null +++ b/src/evidently/ui/managers/projects.py @@ -0,0 +1,216 @@ +import datetime +import json +from typing import List +from typing import Optional +from typing import Union + +from litestar.params import Dependency +from typing_extensions import Annotated + +from evidently._pydantic_compat import parse_obj_as +from evidently.suite.base_suite import Snapshot +from evidently.ui.base import BlobStorage +from evidently.ui.base import DataStorage +from evidently.ui.base import EntityType +from evidently.ui.base import Project +from evidently.ui.base import ProjectMetadataStorage +from evidently.ui.base import SnapshotMetadata +from evidently.ui.errors import NotEnoughPermissions +from evidently.ui.errors import ProjectNotFound +from evidently.ui.managers.auth import AuthManager +from evidently.ui.managers.auth import DefaultRole +from evidently.ui.managers.auth import Permission +from evidently.ui.managers.base import BaseManager +from evidently.ui.type_aliases import ZERO_UUID +from evidently.ui.type_aliases import OrgID +from evidently.ui.type_aliases import ProjectID +from evidently.ui.type_aliases import SnapshotID +from evidently.ui.type_aliases import TeamID +from evidently.ui.type_aliases import UserID + + +class ProjectManager(BaseManager): + project_metadata: ProjectMetadataStorage + auth_manager: AuthManager + blob_storage: BlobStorage + data_storage: DataStorage + + def __init__( + self, + project_metadata: Annotated[ProjectMetadataStorage, Dependency()], + auth_manager: Annotated[AuthManager, Dependency()], + blob_storage: Annotated[BlobStorage, Dependency()], + data_storage: Annotated[DataStorage, Dependency()], + **dependencies, + ): + super().__init__(**dependencies) + self.project_metadata: ProjectMetadataStorage = project_metadata + self.auth_manager: AuthManager = auth_manager + self.blob_storage = blob_storage + self.data_storage = data_storage + + async def create_project( + self, + name: str, + user_id: UserID, + team_id: Optional[TeamID] = None, + description: Optional[str] = None, + org_id: Optional[TeamID] = None, + ) -> Project: + from evidently.ui.dashboards import DashboardConfig + + project = await self.add_project( + Project( + name=name, + description=description, + dashboard=DashboardConfig(name=name, panels=[]), + team_id=team_id, + org_id=org_id, + ), + user_id, + team_id, + org_id, + ) + return project + + async def add_project( + self, project: Project, user_id: UserID, team_id: Optional[TeamID] = None, org_id: Optional[OrgID] = None + ) -> Project: + user = await self.auth_manager.get_or_default_user(user_id) + team = await self.auth_manager.get_team_or_error(team_id) if team_id else None + if team: + if not await self.auth_manager.check_entity_permission( + user.id, EntityType.Team, team.id, Permission.TEAM_CREATE_PROJECT + ): + raise NotEnoughPermissions() + project.team_id = team_id if team_id != ZERO_UUID else None + org_id = team.org_id if team_id else None + project.org_id = org_id + elif org_id: + project.org_id = org_id + team = None + if not await self.auth_manager.check_entity_permission( + user.id, EntityType.Org, org_id, Permission.ORG_WRITE + ): + raise NotEnoughPermissions() + + project.created_at = project.created_at or datetime.datetime.now() + project = (await self.project_metadata.add_project(project, user, team, org_id)).bind(self, user.id) + await self.auth_manager.grant_entity_role( + user.id, + EntityType.Project, + project.id, + user.id, + await self.auth_manager.get_default_role(DefaultRole.OWNER, EntityType.Project), + skip_permission_check=True, + ) + return project + + async def update_project(self, user_id: UserID, project: Project): + user = await self.auth_manager.get_or_default_user(user_id) + if not await self.auth_manager.check_entity_permission( + user.id, EntityType.Project, project.id, Permission.PROJECT_WRITE + ): + raise NotEnoughPermissions() + return await self.project_metadata.update_project(project) + + async def get_project(self, user_id: UserID, project_id: ProjectID) -> Optional[Project]: + user = await self.auth_manager.get_or_default_user(user_id) + if not await self.auth_manager.check_entity_permission( + user.id, EntityType.Project, project_id, Permission.PROJECT_READ + ): + raise ProjectNotFound() + project = await self.project_metadata.get_project(project_id) + if project is None: + return None + return project.bind(self, user.id) + + async def delete_project(self, user_id: UserID, project_id: ProjectID): + user = await self.auth_manager.get_or_default_user(user_id) + if not await self.auth_manager.check_entity_permission( + user.id, EntityType.Project, project_id, Permission.PROJECT_DELETE + ): + raise ProjectNotFound() + return await self.project_metadata.delete_project(project_id) + + async def list_projects(self, user_id: UserID, team_id: Optional[TeamID], org_id: Optional[OrgID]) -> List[Project]: + user = await self.auth_manager.get_or_default_user(user_id) + project_ids = await self.auth_manager.get_available_project_ids(user.id, team_id, org_id) + return [p.bind(self, user.id) for p in await self.project_metadata.list_projects(project_ids)] + + async def search_project( + self, + user_id: UserID, + project_name: str, + team_id: Optional[TeamID], + org_id: Optional[OrgID], + ) -> List[Project]: + user = await self.auth_manager.get_or_default_user(user_id) + project_ids = await self.auth_manager.get_available_project_ids(user.id, team_id, org_id) + return [p.bind(self, user.id) for p in await self.project_metadata.search_project(project_name, project_ids)] + + async def add_snapshot(self, user_id: UserID, project_id: ProjectID, snapshot: Snapshot): + user = await self.auth_manager.get_or_default_user(user_id) + if not await self.auth_manager.check_entity_permission( + user.id, EntityType.Project, project_id, Permission.PROJECT_SNAPSHOT_ADD + ): + raise ProjectNotFound() # todo: better exception + blob = await self.blob_storage.put_snapshot(project_id, snapshot) + await self.project_metadata.add_snapshot(project_id, snapshot, blob) + await self.data_storage.extract_points(project_id, snapshot) + + async def delete_snapshot(self, user_id: UserID, project_id: ProjectID, snapshot_id: SnapshotID): + user = await self.auth_manager.get_or_default_user(user_id) + if not await self.auth_manager.check_entity_permission( + user.id, EntityType.Project, project_id, Permission.PROJECT_SNAPSHOT_DELETE + ): + raise ProjectNotFound() # todo: better exception + # todo + # self.data.remove_points(project_id, snapshot_id) + # self.blob.delete_snapshot(project_id, snapshot_id) + await self.project_metadata.delete_snapshot(project_id, snapshot_id) + + async def list_snapshots( + self, + user_id: UserID, + project_id: ProjectID, + include_reports: bool = True, + include_test_suites: bool = True, + ) -> List[SnapshotMetadata]: + if not await self.auth_manager.check_entity_permission( + user_id, EntityType.Project, project_id, Permission.PROJECT_READ + ): + raise NotEnoughPermissions() + snapshots = await self.project_metadata.list_snapshots(project_id, include_reports, include_test_suites) + for s in snapshots: + s.project.bind(self, user_id) + return snapshots + + async def load_snapshot( + self, + user_id: UserID, + project_id: ProjectID, + snapshot: Union[SnapshotID, SnapshotMetadata], + ) -> Snapshot: + if isinstance(snapshot, SnapshotID): + snapshot = await self.get_snapshot_metadata(user_id, project_id, snapshot) + with self.blob_storage.open_blob(snapshot.blob.id) as f: + return parse_obj_as(Snapshot, json.load(f)) + + async def get_snapshot_metadata( + self, user_id: UserID, project_id: ProjectID, snapshot_id: SnapshotID + ) -> SnapshotMetadata: + if not await self.auth_manager.check_entity_permission( + user_id, EntityType.Project, project_id, Permission.PROJECT_READ + ): + raise NotEnoughPermissions() + meta = await self.project_metadata.get_snapshot_metadata(project_id, snapshot_id) + meta.project.bind(self, user_id) + return meta + + async def reload_snapshots(self, user_id: UserID, project_id: ProjectID): + if not await self.auth_manager.check_entity_permission( + user_id, EntityType.Project, project_id, Permission.PROJECT_READ + ): + raise NotEnoughPermissions() + await self.project_metadata.reload_snapshots(project_id) diff --git a/src/evidently/ui/storage/common.py b/src/evidently/ui/storage/common.py index bf6f3923fd..aefb91012e 100644 --- a/src/evidently/ui/storage/common.py +++ b/src/evidently/ui/storage/common.py @@ -4,16 +4,16 @@ from typing import Set from typing import Tuple -from evidently.ui.base import AuthManager -from evidently.ui.base import DefaultRole from evidently.ui.base import EntityType from evidently.ui.base import Org -from evidently.ui.base import Permission -from evidently.ui.base import Role from evidently.ui.base import Team from evidently.ui.base import User -from evidently.ui.base import UserWithRoles -from evidently.ui.base import get_default_role_permissions +from evidently.ui.managers.auth import AuthManager +from evidently.ui.managers.auth import DefaultRole +from evidently.ui.managers.auth import Permission +from evidently.ui.managers.auth import Role +from evidently.ui.managers.auth import UserWithRoles +from evidently.ui.managers.auth import get_default_role_permissions from evidently.ui.type_aliases import ZERO_UUID from evidently.ui.type_aliases import EntityID from evidently.ui.type_aliases import OrgID diff --git a/src/evidently/ui/storage/local/__init__.py b/src/evidently/ui/storage/local/__init__.py index cd4e829247..48e0fa64b4 100644 --- a/src/evidently/ui/storage/local/__init__.py +++ b/src/evidently/ui/storage/local/__init__.py @@ -1,12 +1,11 @@ from fsspec.implementations.local import LocalFileSystem -from evidently.ui.base import AuthManager -from evidently.ui.base import ProjectManager - +from ...managers.auth import AuthManager +from ...managers.projects import ProjectManager from ..common import NoopAuthManager from .base import FSSpecBlobStorage from .base import InMemoryDataStorage -from .base import JsonFileMetadataStorage +from .base import JsonFileProjectMetadataStorage from .base import LocalState @@ -24,10 +23,13 @@ def start_workspace_watchdog(path: str, state: LocalState): def create_local_project_manager(path: str, autorefresh: bool, auth: AuthManager = None) -> ProjectManager: state = LocalState.load(path, None) - metadata = JsonFileMetadataStorage(path=path, local_state=state) + metadata = JsonFileProjectMetadataStorage(path=path, local_state=state) data = InMemoryDataStorage(path=path, local_state=state) project_manager = ProjectManager( - metadata=metadata, blob=FSSpecBlobStorage(base_path=path), data=data, auth=auth or NoopAuthManager() + project_metadata=metadata, + blob_storage=FSSpecBlobStorage(base_path=path), + data_storage=data, + auth_manager=auth or NoopAuthManager(), ) state.project_manager = project_manager diff --git a/src/evidently/ui/storage/local/base.py b/src/evidently/ui/storage/local/base.py index 8bcbc9671d..d7af3a318d 100644 --- a/src/evidently/ui/storage/local/base.py +++ b/src/evidently/ui/storage/local/base.py @@ -22,9 +22,8 @@ from evidently.ui.base import BlobMetadata from evidently.ui.base import BlobStorage from evidently.ui.base import DataStorage -from evidently.ui.base import MetadataStorage from evidently.ui.base import Project -from evidently.ui.base import ProjectManager +from evidently.ui.base import ProjectMetadataStorage from evidently.ui.base import SnapshotMetadata from evidently.ui.base import Team from evidently.ui.base import User @@ -33,6 +32,7 @@ from evidently.ui.dashboards.test_suites import TestFilter from evidently.ui.dashboards.test_suites import to_period from evidently.ui.errors import ProjectNotFound +from evidently.ui.managers.projects import ProjectManager from evidently.ui.storage.common import NO_TEAM from evidently.ui.storage.common import NO_USER from evidently.ui.type_aliases import BlobID @@ -188,7 +188,7 @@ def reload_snapshot(self, project: Project, snapshot_id: SnapshotID, skip_errors raise ValueError(f"{snapshot_id} is malformed") from e -class JsonFileMetadataStorage(MetadataStorage): +class JsonFileProjectMetadataStorage(ProjectMetadataStorage): path: str _state: LocalState = PrivateAttr(None) diff --git a/src/evidently/ui/workspace/cloud.py b/src/evidently/ui/workspace/cloud.py index d231e81d86..9bc64357ef 100644 --- a/src/evidently/ui/workspace/cloud.py +++ b/src/evidently/ui/workspace/cloud.py @@ -22,9 +22,9 @@ from evidently.ui.api.models import OrgModel from evidently.ui.api.models import TeamModel from evidently.ui.base import Org -from evidently.ui.base import ProjectManager from evidently.ui.base import Team from evidently.ui.datasets import DatasetSourceType +from evidently.ui.managers.projects import ProjectManager from evidently.ui.storage.common import NoopAuthManager from evidently.ui.type_aliases import STR_UUID from evidently.ui.type_aliases import ZERO_UUID @@ -34,7 +34,7 @@ from evidently.ui.type_aliases import TeamID from evidently.ui.workspace.remote import NoopBlobStorage from evidently.ui.workspace.remote import NoopDataStorage -from evidently.ui.workspace.remote import RemoteMetadataStorage +from evidently.ui.workspace.remote import RemoteProjectMetadataStorage from evidently.ui.workspace.remote import T from evidently.ui.workspace.view import WorkspaceView @@ -54,7 +54,7 @@ class Cookie(NamedTuple): ) -class CloudMetadataStorage(RemoteMetadataStorage): +class CloudMetadataStorage(RemoteProjectMetadataStorage): def __init__(self, base_url: str, token: str, token_cookie_name: str): self.token = token self.token_cookie_name = token_cookie_name @@ -236,10 +236,10 @@ def __init__( ) pm = ProjectManager( - metadata=meta, - blob=(NoopBlobStorage()), - data=(NoopDataStorage()), - auth=(CloudAuthManager()), + project_metadata=meta, + blob_storage=(NoopBlobStorage()), + data_storage=(NoopDataStorage()), + auth_manager=(CloudAuthManager()), ) super().__init__( user_id, @@ -247,16 +247,16 @@ def __init__( ) def create_org(self, name: str) -> Org: - assert isinstance(self.project_manager.metadata, CloudMetadataStorage) - return self.project_manager.metadata.create_org(Org(name=name)).to_org() + assert isinstance(self.project_manager.project_metadata, CloudMetadataStorage) + return self.project_manager.project_metadata.create_org(Org(name=name)).to_org() def list_orgs(self) -> List[Org]: - assert isinstance(self.project_manager.metadata, CloudMetadataStorage) - return [o.to_org() for o in self.project_manager.metadata.list_orgs()] + assert isinstance(self.project_manager.project_metadata, CloudMetadataStorage) + return [o.to_org() for o in self.project_manager.project_metadata.list_orgs()] def create_team(self, name: str, org_id: OrgID) -> Team: - assert isinstance(self.project_manager.metadata, CloudMetadataStorage) - return self.project_manager.metadata.create_team(Team(name=name, org_id=org_id), org_id).to_team() + assert isinstance(self.project_manager.project_metadata, CloudMetadataStorage) + return self.project_manager.project_metadata.create_team(Team(name=name, org_id=org_id), org_id).to_team() def add_dataset( self, @@ -268,7 +268,7 @@ def add_dataset( dataset_source: DatasetSourceType = DatasetSourceType.file, ) -> DatasetID: file: Union[NamedBytesIO, BinaryIO] - assert isinstance(self.project_manager.metadata, CloudMetadataStorage) + assert isinstance(self.project_manager.project_metadata, CloudMetadataStorage) if isinstance(data_or_path, str): file = open(data_or_path, "rb") elif isinstance(data_or_path, pd.DataFrame): @@ -280,15 +280,15 @@ def add_dataset( if isinstance(project_id, str): project_id = ProjectID(project_id) try: - return self.project_manager.metadata.add_dataset( + return self.project_manager.project_metadata.add_dataset( file, name, project_id, description, column_mapping, dataset_source ) finally: file.close() def load_dataset(self, dataset_id: DatasetID) -> pd.DataFrame: - assert isinstance(self.project_manager.metadata, CloudMetadataStorage) - return self.project_manager.metadata.load_dataset(dataset_id) + assert isinstance(self.project_manager.project_metadata, CloudMetadataStorage) + return self.project_manager.project_metadata.load_dataset(dataset_id) def add_report_with_data(self, project_id: STR_UUID, report: Report): self.add_report(project_id, report) diff --git a/src/evidently/ui/workspace/remote.py b/src/evidently/ui/workspace/remote.py index d55f31efff..3efb399906 100644 --- a/src/evidently/ui/workspace/remote.py +++ b/src/evidently/ui/workspace/remote.py @@ -25,9 +25,8 @@ from evidently.ui.base import BlobMetadata from evidently.ui.base import BlobStorage from evidently.ui.base import DataStorage -from evidently.ui.base import MetadataStorage from evidently.ui.base import Project -from evidently.ui.base import ProjectManager +from evidently.ui.base import ProjectMetadataStorage from evidently.ui.base import SnapshotMetadata from evidently.ui.base import Team from evidently.ui.base import User @@ -36,6 +35,7 @@ from evidently.ui.dashboards.test_suites import TestFilter from evidently.ui.errors import EvidentlyServiceError from evidently.ui.errors import ProjectNotFound +from evidently.ui.managers.projects import ProjectManager from evidently.ui.storage.common import SECRET_HEADER_NAME from evidently.ui.storage.common import NoopAuthManager from evidently.ui.type_aliases import ZERO_UUID @@ -143,7 +143,7 @@ def _request( return response -class RemoteMetadataStorage(MetadataStorage, RemoteBase): +class RemoteProjectMetadataStorage(ProjectMetadataStorage, RemoteBase): def __init__(self, base_url: str, secret: Optional[str] = None): self.base_url = base_url self.secret = secret @@ -284,7 +284,7 @@ async def load_points_as_type( class RemoteWorkspaceView(WorkspaceView): def verify(self): try: - response = self.project_manager.metadata._request("/api/version", "GET") + response = self.project_manager.project_metadata._request("/api/version", "GET") assert response.json()["application"] == EVIDENTLY_APPLICATION_NAME except (HTTPError, JSONDecodeError, KeyError, AssertionError) as e: raise ValueError(f"Evidenly API not available at {self.base_url}") from e @@ -293,10 +293,10 @@ def __init__(self, base_url: str, secret: Optional[str] = None): self.base_url = base_url self.secret = secret pm = ProjectManager( - metadata=(RemoteMetadataStorage(base_url=self.base_url, secret=self.secret)), - blob=(NoopBlobStorage()), - data=(NoopDataStorage()), - auth=(NoopAuthManager()), + project_metadata=(RemoteProjectMetadataStorage(base_url=self.base_url, secret=self.secret)), + blob_storage=(NoopBlobStorage()), + data_storage=(NoopDataStorage()), + auth_manager=(NoopAuthManager()), ) super().__init__(None, pm) self.verify() diff --git a/src/evidently/ui/workspace/view.py b/src/evidently/ui/workspace/view.py index 5fb521ec71..22f8a615a1 100644 --- a/src/evidently/ui/workspace/view.py +++ b/src/evidently/ui/workspace/view.py @@ -9,8 +9,8 @@ from evidently import ColumnMapping from evidently.suite.base_suite import Snapshot from evidently.ui.base import Project -from evidently.ui.base import ProjectManager from evidently.ui.datasets import DatasetSourceType +from evidently.ui.managers.projects import ProjectManager from evidently.ui.type_aliases import STR_UUID from evidently.ui.type_aliases import ZERO_UUID from evidently.ui.type_aliases import DatasetID diff --git a/src/evidently/utils/numpy_encoder.py b/src/evidently/utils/numpy_encoder.py index e205a42851..db7a10d465 100644 --- a/src/evidently/utils/numpy_encoder.py +++ b/src/evidently/utils/numpy_encoder.py @@ -2,6 +2,7 @@ import json import typing import uuid +from functools import partial from typing import Callable from typing import Tuple from typing import Type @@ -59,3 +60,6 @@ def default(self, o): return None return json.JSONEncoder.default(self, o) + + +numpy_dumps = partial(json.dumps, cls=NumpyEncoder) diff --git a/src/evidently/utils/sync.py b/src/evidently/utils/sync.py index 81efbb5f8e..10bd61eb0d 100644 --- a/src/evidently/utils/sync.py +++ b/src/evidently/utils/sync.py @@ -21,12 +21,13 @@ def async_to_sync(awaitable: Awaitable[TA]) -> TA: future = asyncio.run_coroutine_threadsafe(awaitable, _loop) return future.result() except RuntimeError: - new_loop = asyncio.new_event_loop() - asyncio.set_event_loop(new_loop) - try: - return new_loop.run_until_complete(awaitable) - finally: - new_loop.close() + pass + new_loop = asyncio.new_event_loop() + asyncio.set_event_loop(new_loop) + try: + return new_loop.run_until_complete(awaitable) + finally: + new_loop.close() def sync_api(f: Callable[..., Awaitable[TA]]) -> Callable[..., TA]: diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py index e0a2dfda27..7265889b0f 100644 --- a/tests/ui/conftest.py +++ b/tests/ui/conftest.py @@ -11,12 +11,12 @@ from evidently._pydantic_compat import BaseModel from evidently.ui.app import create_app from evidently.ui.base import Project -from evidently.ui.base import ProjectManager from evidently.ui.components.base import Component from evidently.ui.components.base import ComponentContext from evidently.ui.components.storage import LocalStorageComponent from evidently.ui.local_service import LocalConfig from evidently.ui.local_service import LocalServiceComponent +from evidently.ui.managers.projects import ProjectManager from evidently.ui.security.service import SecurityService from evidently.utils import NumpyEncoder diff --git a/tests/ui/test_app.py b/tests/ui/test_app.py index 04d7ca30df..f9fd6982cd 100644 --- a/tests/ui/test_app.py +++ b/tests/ui/test_app.py @@ -23,11 +23,11 @@ from evidently.renderers.html_widgets import counter from evidently.suite.base_suite import ContextPayload from evidently.suite.base_suite import Snapshot -from evidently.ui.base import ProjectManager from evidently.ui.dashboards import CounterAgg from evidently.ui.dashboards import DashboardPanelCounter from evidently.ui.dashboards import ReportFilter from evidently.ui.dashboards.base import DashboardPanel +from evidently.ui.managers.projects import ProjectManager from evidently.ui.storage.local import FSSpecBlobStorage from evidently.ui.type_aliases import ZERO_UUID from evidently.utils import NumpyEncoder @@ -411,7 +411,7 @@ async def test_reload_project(test_client: TestClient, project_manager: ProjectM await project_manager.add_snapshot(ZERO_UUID, project.id, mock_snapshot) assert len(await project_manager.list_snapshots(ZERO_UUID, project.id)) == 1 - blob = project_manager.blob + blob = project_manager.blob_storage assert isinstance(blob, FSSpecBlobStorage) snapshot_path = os.path.join(blob.base_path, blob.get_snapshot_blob_id(project.id, mock_snapshot)) snapshot_id2 = new_id()