diff --git a/examples/basic_usage.py b/examples/basic_usage.py index f2d326a62..964764a6c 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -92,9 +92,9 @@ project.delete_item("my_int_2") -# You can use `Project.list_keys` to display all the keys in your project: +# You can use `Project.list_item_keys` to display all the keys in your project: -project.list_keys() +project.list_item_keys() # ## Storing a string diff --git a/pyproject.toml b/pyproject.toml index fa5f7d176..14d1f57ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ test = [ "httpx", "matplotlib", "pandas", + "pillow", "plotly", "pre-commit", "pytest", diff --git a/requirements-test.txt b/requirements-test.txt index f3dfd5be9..7ea5929a2 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -106,7 +106,9 @@ packaging==24.1 pandas==2.2.2 # via skore (pyproject.toml) pillow==10.4.0 - # via matplotlib + # via + # matplotlib + # skore (pyproject.toml) platformdirs==4.3.6 # via virtualenv plotly==5.24.1 diff --git a/src/skore/cli/create_project.py b/src/skore/cli/create_project.py index 0710cdc57..78802615d 100644 --- a/src/skore/cli/create_project.py +++ b/src/skore/cli/create_project.py @@ -145,12 +145,12 @@ def __create(project_name: str | Path, working_dir: Path | None = None) -> Path: f"Unable to create project file '{items_dir}'." ) from e - layouts_dir = project_directory / "layouts" + views_dir = project_directory / "views" try: - layouts_dir.mkdir() + views_dir.mkdir() except Exception as e: raise ProjectCreationError( - f"Unable to create project file '{layouts_dir}'." + f"Unable to create project file '{views_dir}'." ) from e logger.info(f"Project file '{project_directory}' was successfully created.") diff --git a/src/skore/layout/__init__.py b/src/skore/layout/__init__.py deleted file mode 100644 index 54c2e7010..000000000 --- a/src/skore/layout/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Implement layout primitives and storage.""" - -from skore.layout.layout import Layout -from skore.layout.layout_repository import LayoutRepository - -__all__ = ["Layout", "LayoutRepository"] diff --git a/src/skore/layout/layout_repository.py b/src/skore/layout/layout_repository.py deleted file mode 100644 index 59f80e7e1..000000000 --- a/src/skore/layout/layout_repository.py +++ /dev/null @@ -1,65 +0,0 @@ -"""LayoutRepository.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from skore.layout import Layout - from skore.persistence.abstract_storage import AbstractStorage - - -class LayoutRepository: - """ - A repository for managing storage and retrieval of layouts. - - This class provides methods to get, put, and delete layouts from a storage system. - """ - - def __init__(self, storage: AbstractStorage): - """ - Initialize the LayoutRepository with a storage system. - - Parameters - ---------- - storage : AbstractStorage - The storage system to be used by the repository. - """ - self.storage = storage - - def get_layout(self) -> Layout: - """ - Retrieve the layout from storage. - - Returns - ------- - Layout - The retrieved layout. - """ - return self.storage["layout"] - - def put_layout(self, layout: Layout): - """ - Store a layout in storage. - - Parameters - ---------- - layout : Layout - The layout to be stored. - """ - self.storage["layout"] = layout - - def delete_layout(self): - """Delete the layout from storage.""" - del self.storage["layout"] - - def keys(self) -> list[str]: - """ - Get all keys of items stored in the repository. - - Returns - ------- - list[str] - A list of all keys in the storage. - """ - return list(self.storage.keys()) diff --git a/src/skore/project.py b/src/skore/project.py index 9dfd6aa32..90a57fa59 100644 --- a/src/skore/project.py +++ b/src/skore/project.py @@ -15,8 +15,9 @@ SklearnBaseEstimatorItem, object_to_item, ) -from skore.layout import Layout, LayoutRepository from skore.persistence.disk_cache_storage import DirectoryDoesNotExist, DiskCacheStorage +from skore.view.view import View +from skore.view.view_repository import ViewRepository logger = logging.getLogger(__name__) @@ -31,10 +32,10 @@ class Project: def __init__( self, item_repository: ItemRepository, - layout_repository: LayoutRepository, + view_repository: ViewRepository, ): self.item_repository = item_repository - self.layout_repository = layout_repository + self.view_repository = view_repository @singledispatchmethod def put(self, key: str, value: Any, on_error: Literal["warn", "raise"] = "warn"): @@ -130,24 +131,29 @@ def get_item(self, key: str) -> Item: """Add the Item corresponding to `key` from the Project.""" return self.item_repository.get_item(key) - def list_keys(self) -> list[str]: - """List all keys in the Project.""" + def list_item_keys(self) -> list[str]: + """List all item keys in the Project.""" return self.item_repository.keys() def delete_item(self, key: str): """Delete an item from the Project.""" self.item_repository.delete_item(key) - def put_report_layout(self, layout: Layout): - """Add a report layout to the Project.""" - self.layout_repository.put_layout(layout) + def put_view(self, key: str, view: View): + """Add a view to the Project.""" + self.view_repository.put_view(key, view) - def get_report_layout(self) -> Layout: - """Get the report layout corresponding to `key` from the Project.""" - try: - return self.layout_repository.get_layout() - except KeyError: - return [] + def get_view(self, key: str) -> View: + """Get the view corresponding to `key` from the Project.""" + return self.view_repository.get_view(key) + + def delete_view(self, key: str): + """Delete the view corresponding to `key` from the Project.""" + return self.view_repository.delete_view(key) + + def list_view_keys(self) -> list[str]: + """List all view keys in the Project.""" + return self.view_repository.keys() class ProjectLoadError(Exception): @@ -174,11 +180,11 @@ def load(project_name: str | Path) -> Project: # FIXME should those hardcoded string be factorized somewhere ? item_storage = DiskCacheStorage(directory=Path(path) / "items") item_repository = ItemRepository(storage=item_storage) - layout_storage = DiskCacheStorage(directory=Path(path) / "layouts") - layout_repository = LayoutRepository(storage=layout_storage) + view_storage = DiskCacheStorage(directory=Path(path) / "views") + view_repository = ViewRepository(storage=view_storage) project = Project( item_repository=item_repository, - layout_repository=layout_repository, + view_repository=view_repository, ) except DirectoryDoesNotExist as e: missing_directory = e.args[0].split()[1] diff --git a/src/skore/ui/app.py b/src/skore/ui/app.py index bb232500d..995342f8b 100644 --- a/src/skore/ui/app.py +++ b/src/skore/ui/app.py @@ -5,9 +5,8 @@ from fastapi.staticfiles import StaticFiles from skore.project import Project, load - -from .dependencies import get_static_path -from .report import router as report_router +from skore.ui.dependencies import get_static_path +from skore.ui.report import router as report_router def create_app(project: Project | None = None) -> FastAPI: diff --git a/src/skore/ui/report.py b/src/skore/ui/report.py index 518daf66c..1f17dbf7b 100644 --- a/src/skore/ui/report.py +++ b/src/skore/ui/report.py @@ -14,30 +14,35 @@ from skore.item.pandas_dataframe_item import PandasDataFrameItem from skore.item.primitive_item import PrimitiveItem from skore.item.sklearn_base_estimator_item import SklearnBaseEstimatorItem -from skore.layout import Layout from skore.project import Project +from skore.view.view import Layout, View from .dependencies import get_static_path, get_templates router = APIRouter() +@dataclass +class SerializedItem: + """Serialized item.""" + + media_type: str + value: Any + updated_at: str + created_at: str + + @dataclass class SerializedProject: """Serialized project, to be sent to the frontend.""" layout: Layout - items: dict[str, dict[str, Any]] + items: dict[str, SerializedItem] def __serialize_project(project: Project) -> SerializedProject: - try: - layout = project.get_report_layout() - except KeyError: - layout = [] - items = {} - for key in project.list_keys(): + for key in project.list_item_keys(): item = project.get_item(key) media_type = None @@ -59,14 +64,22 @@ def __serialize_project(project: Project) -> SerializedProject: else: raise ValueError(f"Item {item} is not a known item type.") - items[key] = { - "media_type": media_type, - "value": value, - "updated_at": item.updated_at, - "created_at": item.created_at, - } + items[key] = SerializedItem( + media_type=media_type, + value=value, + updated_at=item.updated_at, + created_at=item.created_at, + ) - return SerializedProject(layout=layout, items=items) + try: + layout = project.get_view("layout").layout + except KeyError: + layout = [] + + return SerializedProject( + layout=layout, + items=items, + ) @router.get("/items") @@ -86,7 +99,7 @@ async def share_store( """Serve an inlined shareable HTML page.""" project = request.app.state.project - # Get static assets to inject them into the report template + # Get static assets to inject them into the view template def read_asset_content(filename: str): with open(static_path / filename) as f: return f.read() @@ -109,8 +122,11 @@ def read_asset_content(filename: str): @router.put("/report/layout", status_code=201) -async def set_report_layout(request: Request, layout: Layout): - """Set the report layout.""" - project = request.app.state.project - project.put_report_layout(layout) +async def set_view_layout(request: Request, layout: Layout): + """Set the view layout.""" + project: Project = request.app.state.project + + view = View(layout=layout) + project.put_view("layout", view) + return __serialize_project(project) diff --git a/src/skore/view/__init__.py b/src/skore/view/__init__.py new file mode 100644 index 000000000..18ffb934f --- /dev/null +++ b/src/skore/view/__init__.py @@ -0,0 +1 @@ +"""Implement view primitives and storage.""" diff --git a/src/skore/layout/layout.py b/src/skore/view/view.py similarity index 54% rename from src/skore/layout/layout.py rename to src/skore/view/view.py index 440fc1a77..942ad8726 100644 --- a/src/skore/layout/layout.py +++ b/src/skore/view/view.py @@ -1,4 +1,4 @@ -"""Layout models.""" +"""Project View models.""" from dataclasses import dataclass from enum import StrEnum @@ -21,3 +21,19 @@ class LayoutItem: Layout = list[LayoutItem] + + +@dataclass +class View: + """A view of a Project. + + Examples + -------- + >>> View(layout=[ + ... {"key": "a", "size": "medium"}, + ... {"key": "b", "size": "small"}, + ... ]) + View(...) + """ + + layout: Layout diff --git a/src/skore/view/view_repository.py b/src/skore/view/view_repository.py new file mode 100644 index 000000000..8ec7dcfc9 --- /dev/null +++ b/src/skore/view/view_repository.py @@ -0,0 +1,75 @@ +"""Implement a repository for Views.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from skore.persistence.abstract_storage import AbstractStorage + from skore.view.view import View + + +class ViewRepository: + """ + A repository for managing storage and retrieval of Views. + + This class provides methods to get, put, and delete Views from a storage system. + """ + + def __init__(self, storage: AbstractStorage): + """ + Initialize the ViewRepository with a storage system. + + Parameters + ---------- + storage : AbstractStorage + The storage system to be used by the repository. + """ + self.storage = storage + + def get_view(self, key: str) -> View: + """ + Retrieve the View from storage. + + Parameters + ---------- + key : str + A key at which to look for a View. + + Returns + ------- + View + The retrieved View. + + Raises + ------ + KeyError + When `key` is not present in the underlying storage. + """ + return self.storage[key] + + def put_view(self, key: str, view: View): + """ + Store a view in storage. + + Parameters + ---------- + view : View + The view to be stored. + """ + self.storage[key] = view + + def delete_view(self, key: str): + """Delete the view from storage.""" + del self.storage[key] + + def keys(self) -> list[str]: + """ + Get all keys of items stored in the repository. + + Returns + ------- + list[str] + A list of all keys in the storage. + """ + return list(self.storage.keys()) diff --git a/tests/integration/ui/test_ui.py b/tests/integration/ui/test_ui.py index b72a2e0a8..7f83947be 100644 --- a/tests/integration/ui/test_ui.py +++ b/tests/integration/ui/test_ui.py @@ -1,19 +1,19 @@ import pytest from fastapi.testclient import TestClient from skore.item.item_repository import ItemRepository -from skore.layout.layout_repository import LayoutRepository from skore.persistence.in_memory_storage import InMemoryStorage from skore.project import Project from skore.ui.app import create_app +from skore.view.view_repository import ViewRepository @pytest.fixture def project(): item_repository = ItemRepository(storage=InMemoryStorage()) - layout_repository = LayoutRepository(storage=InMemoryStorage()) + view_repository = ViewRepository(storage=InMemoryStorage()) return Project( item_repository=item_repository, - layout_repository=layout_repository, + view_repository=view_repository, ) @@ -37,7 +37,7 @@ def test_get_items(client, project): response = client.get("/api/items") assert response.status_code == 200 - assert response.json() == {"items": {}, "layout": []} + assert response.json() == {"layout": [], "items": {}} project.put("test", "test") item = project.get_item("test") @@ -57,7 +57,7 @@ def test_get_items(client, project): } -def test_share_report(client, project): +def test_share_view(client, project): project.put("test", "test") response = client.post("/api/report/share", json=[{"key": "test", "size": "large"}]) @@ -65,6 +65,17 @@ def test_share_report(client, project): assert b"" in response.content -def test_put_report_layout(client): - response = client.put("/api/report/layout", json=[{"key": "test", "size": "large"}]) +def test_put_view_layout(client): + response = client.put( + "/api/report/layout", + json=[{"key": "test", "size": "large"}], + ) + assert response.status_code == 201 + + +def test_put_view_layout_with_slash_in_name(client): + response = client.put( + "/api/report/layout", + json=[{"key": "test", "size": "large"}], + ) assert response.status_code == 201 diff --git a/tests/unit/layout/test_layout_repository.py b/tests/unit/layout/test_layout_repository.py deleted file mode 100644 index 1a9555c67..000000000 --- a/tests/unit/layout/test_layout_repository.py +++ /dev/null @@ -1,39 +0,0 @@ -import pytest -from skore.layout import LayoutRepository -from skore.layout.layout import LayoutItem, LayoutItemSize -from skore.persistence.in_memory_storage import InMemoryStorage - - -@pytest.fixture -def layout_repository(): - return LayoutRepository(InMemoryStorage()) - - -def test_get(layout_repository): - layout = [ - LayoutItem(key="key1", size=LayoutItemSize.LARGE), - LayoutItem(key="key2", size=LayoutItemSize.SMALL), - ] - - layout_repository.put_layout(layout) - - assert layout_repository.get_layout() == layout - - -def test_get_with_no_put(layout_repository): - with pytest.raises(KeyError): - layout_repository.get_layout() - - -def test_delete(layout_repository): - layout_repository.put_layout([]) - - layout_repository.delete_layout() - - with pytest.raises(KeyError): - layout_repository.get_layout() - - -def test_delete_with_no_put(layout_repository): - with pytest.raises(KeyError): - layout_repository.delete_layout() diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index 94c390687..5fa8f11ef 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -11,17 +11,17 @@ from PIL import Image from sklearn.ensemble import RandomForestClassifier from skore.item import ItemRepository -from skore.layout import LayoutRepository -from skore.layout.layout import LayoutItem, LayoutItemSize from skore.persistence.in_memory_storage import InMemoryStorage from skore.project import Project, ProjectLoadError, ProjectPutError, load +from skore.view.view import LayoutItem, LayoutItemSize, View +from skore.view.view_repository import ViewRepository @pytest.fixture def project(): return Project( item_repository=ItemRepository(InMemoryStorage()), - layout_repository=LayoutRepository(InMemoryStorage()), + view_repository=ViewRepository(InMemoryStorage()), ) @@ -116,7 +116,7 @@ def test_load(tmp_path): project_path = tmp_path.parent / (tmp_path.name + ".skore") os.mkdir(project_path) os.mkdir(project_path / "items") - os.mkdir(project_path / "layouts") + os.mkdir(project_path / "views") p = load(project_path) assert isinstance(p, Project) @@ -127,7 +127,7 @@ def test_put(project): project.put("key3", 3) project.put("key4", 4) - assert project.list_keys() == ["key1", "key2", "key3", "key4"] + assert project.list_item_keys() == ["key1", "key2", "key3", "key4"] def test_put_twice(project): @@ -141,7 +141,7 @@ def test_put_int_key(project, caplog): # Warns that 0 is not a string, but doesn't raise project.put(0, "hello") assert len(caplog.record_tuples) == 1 - assert project.list_keys() == [] + assert project.list_item_keys() == [] def test_get(project): @@ -156,7 +156,7 @@ def test_delete(project): project.put("key1", 1) project.delete_item("key1") - assert project.list_keys() == [] + assert project.list_item_keys() == [] with pytest.raises(KeyError): project.delete_item("key2") @@ -165,28 +165,37 @@ def test_delete(project): def test_keys(project): project.put("key1", 1) project.put("key2", 2) - assert project.list_keys() == ["key1", "key2"] + assert project.list_item_keys() == ["key1", "key2"] -def test_report_layout(project): +def test_view(project): layout = [ LayoutItem(key="key1", size=LayoutItemSize.LARGE), LayoutItem(key="key2", size=LayoutItemSize.SMALL), ] - project.put_report_layout(layout) - assert project.get_report_layout() == layout + view = View(layout=layout) + + project.put_view("view", view) + assert project.get_view("view") == view + + +def test_list_view_keys(project): + view = View(layout=[]) + + project.put_view("view", view) + assert project.list_view_keys() == ["view"] def test_put_several_happy_path(project): project.put({"a": "foo", "b": "bar"}) - assert project.list_keys() == ["a", "b"] + assert project.list_item_keys() == ["a", "b"] def test_put_several_canonical(project): """Use `put_several` instead of the `put` alias.""" project.put_several({"a": "foo", "b": "bar"}) - assert project.list_keys() == ["a", "b"] + assert project.list_item_keys() == ["a", "b"] def test_put_several_some_errors(project, caplog): @@ -198,25 +207,25 @@ def test_put_several_some_errors(project, caplog): } ) assert len(caplog.record_tuples) == 3 - assert project.list_keys() == [] + assert project.list_item_keys() == [] def test_put_several_nested(project): project.put({"a": {"b": "baz"}}) - assert project.list_keys() == ["a"] + assert project.list_item_keys() == ["a"] assert project.get("a") == {"b": "baz"} def test_put_several_error(project): """If some key-value pairs are wrong, add all that are valid and print a warning.""" project.put({"a": "foo", "b": (lambda: "unsupported object")}) - assert project.list_keys() == ["a"] + assert project.list_item_keys() == ["a"] def test_put_key_is_a_tuple(project): """If key is not a string, warn.""" project.put(("a", "foo"), ("b", "bar")) - assert project.list_keys() == [] + assert project.list_item_keys() == [] def test_put_key_is_a_set(project): diff --git a/tests/unit/view/test_view_repository.py b/tests/unit/view/test_view_repository.py new file mode 100644 index 000000000..0b949e9d6 --- /dev/null +++ b/tests/unit/view/test_view_repository.py @@ -0,0 +1,41 @@ +import pytest +from skore.persistence.in_memory_storage import InMemoryStorage +from skore.view.view import LayoutItem, LayoutItemSize, View +from skore.view.view_repository import ViewRepository + + +@pytest.fixture +def view_repository(): + return ViewRepository(InMemoryStorage()) + + +def test_get(view_repository): + view = View( + layout=[ + LayoutItem(key="key1", size=LayoutItemSize.LARGE), + LayoutItem(key="key2", size=LayoutItemSize.SMALL), + ] + ) + + view_repository.put_view("view", view) + + assert view_repository.get_view("view") == view + + +def test_get_with_no_put(view_repository): + with pytest.raises(KeyError): + view_repository.get_view("view") + + +def test_delete(view_repository): + view_repository.put_view("view", View(layout=[])) + + view_repository.delete_view("view") + + with pytest.raises(KeyError): + view_repository.get_view("view") + + +def test_delete_with_no_put(view_repository): + with pytest.raises(KeyError): + view_repository.delete_view("view")