diff --git a/skore/src/skore/__init__.py b/skore/src/skore/__init__.py index b25cdef045..98ac5587ab 100644 --- a/skore/src/skore/__init__.py +++ b/skore/src/skore/__init__.py @@ -10,7 +10,8 @@ from skore._config import config_context, get_config, set_config from skore._externals._sklearn_compat import parse_version -from skore._login import login +from skore._project.login import login +from skore._project.project import Project from skore._sklearn import ( ComparisonReport, ConfusionMatrixDisplay, @@ -33,7 +34,6 @@ ) from skore._utils._patch import setup_jupyter_display from skore._utils._show_versions import show_versions -from skore.project import Project plt.ion() setup_jupyter_display() diff --git a/skore/src/skore/project/__init__.py b/skore/src/skore/_project/__init__.py similarity index 50% rename from skore/src/skore/project/__init__.py rename to skore/src/skore/_project/__init__.py index cadbd8144c..5294755b04 100644 --- a/skore/src/skore/project/__init__.py +++ b/skore/src/skore/_project/__init__.py @@ -1,5 +1 @@ """Alias top level function and class of the project submodule.""" - -from skore.project.project import Project - -__all__ = ["Project"] diff --git a/skore/src/skore/project/_summary.py b/skore/src/skore/_project/_summary.py similarity index 97% rename from skore/src/skore/project/_summary.py rename to skore/src/skore/_project/_summary.py index addadf870d..1f153ceb75 100644 --- a/skore/src/skore/project/_summary.py +++ b/skore/src/skore/_project/_summary.py @@ -7,13 +7,12 @@ from pandas import Categorical, DataFrame, Index, MultiIndex, RangeIndex -from skore import ComparisonReport from skore._utils._jupyter import _jupyter_dependencies_available if TYPE_CHECKING: from typing import Literal - from skore import CrossValidationReport, EstimatorReport + from skore import ComparisonReport, CrossValidationReport, EstimatorReport class Summary(DataFrame): @@ -91,6 +90,8 @@ def reports( return_as : {"list", "comparison"}, default="list" In what form the reports should be returned. """ + from skore import ComparisonReport + if self.empty: return [] @@ -130,7 +131,8 @@ def _repr_mimebundle_(self, include=None, exclude=None): stacklevel=2, ) return {"text/html": DataFrame._repr_html_(self)} - from skore.project._widget import ModelExplorerWidget + + from skore._project._widget import ModelExplorerWidget self._plot_widget = ModelExplorerWidget(dataframe=self) return {"text/html": self._plot_widget.display()} diff --git a/skore/src/skore/project/_widget.py b/skore/src/skore/_project/_widget.py similarity index 100% rename from skore/src/skore/project/_widget.py rename to skore/src/skore/_project/_widget.py diff --git a/skore/src/skore/_login.py b/skore/src/skore/_project/login.py similarity index 82% rename from skore/src/skore/_login.py rename to skore/src/skore/_project/login.py index eb99a066aa..b7a1f5af51 100644 --- a/skore/src/skore/_login.py +++ b/skore/src/skore/_project/login.py @@ -1,13 +1,14 @@ """Configure storage backend credentials.""" -from importlib.metadata import entry_points from logging import getLogger -from typing import Literal + +from skore._project import plugin +from skore._project.types import ProjectMode logger = getLogger(__name__) -def login(*, mode: Literal["local", "hub"] = "hub", **kwargs): +def login(*, mode: ProjectMode = "hub", **kwargs): """ Login to the storage backend. @@ -55,12 +56,6 @@ def login(*, mode: Literal["local", "hub"] = "hub", **kwargs): logger.debug("Login to local storage.") return - mode = "hub" - plugins = entry_points(group="skore.plugins.login") - - if mode not in plugins.names: - raise ValueError(f"Unknown mode `{mode}`. Please install `skore[{mode}]`.") - logger.debug("Login to hub storage.") - return plugins[mode].load()(**kwargs) + return plugin.get(group="skore.plugins.login", mode="hub")(**kwargs) diff --git a/skore/src/skore/_project/plugin.py b/skore/src/skore/_project/plugin.py new file mode 100644 index 0000000000..1a37ce3502 --- /dev/null +++ b/skore/src/skore/_project/plugin.py @@ -0,0 +1,56 @@ +"""Tools used to interact with ``skore`` plugin.""" + +from importlib.metadata import entry_points +from typing import Any, get_args + +from skore._project.types import PluginGroup, ProjectMode + +GROUPS = get_args(PluginGroup) +MODES = get_args(ProjectMode) + + +def get(*, group: PluginGroup, mode: ProjectMode) -> Any: + """ + Load and return a ``skore`` plugin implementation for the given group and mode. + + There are currently two types of plugins allowed: + - the classes implementing the ``Project`` API, registered under the + ``skore.plugins.project`` group, + - the functions used to login, registered under the ``skore.plugins.login`` group. + + This function uses internally the python entry points mechanism: each package + compatible with ``skore`` could expose its own plugins, as long as they are + registered in the right groups and comply with APIs. + + It is usually used to retrieve the plugins exposed by ``skore-local-project`` + or ``skore-hub-project``. + + Parameters + ---------- + group : PluginGroup + The group of plugin to search for. Must be one of: + - "skore.plugins.project" + - "skore.plugins.login" + + mode : ProjectMode + The project mode used to select the plugin implementation. + Must be one of: + - "hub" + - "local" + + Returns + ------- + Any + The loaded plugin object corresponding to the given group and mode. + The exact return type depends on the registered plugin implementation: class or + function. + """ + assert group in GROUPS, f"`group` must be in {GROUPS} (found {group})" + assert mode in MODES, f"`mode` must be in {MODES} (found {mode})" + + plugins = entry_points(group=group) + + if mode not in plugins.names: + raise ValueError(f"Unknown mode `{mode}`. Please install `skore[{mode}]`.") + + return plugins[mode].load() diff --git a/skore/src/skore/project/project.py b/skore/src/skore/_project/project.py similarity index 75% rename from skore/src/skore/project/project.py rename to skore/src/skore/_project/project.py index 60f469268d..0251480149 100644 --- a/skore/src/skore/project/project.py +++ b/skore/src/skore/_project/project.py @@ -3,11 +3,14 @@ from __future__ import annotations import re -from importlib.metadata import entry_points -from typing import Any, Literal +from typing import TYPE_CHECKING, Any -from skore import CrossValidationReport, EstimatorReport -from skore.project._summary import Summary +from skore._project import plugin +from skore._project._summary import Summary +from skore._project.types import ProjectMode + +if TYPE_CHECKING: + from skore import CrossValidationReport, EstimatorReport class Project: @@ -22,22 +25,22 @@ class Project: insert a key-report pair into the project, to obtain the metadata/metrics of the inserted reports and to get a specific report by its id. - Two mutually exclusive modes are available and can be configured using the ``name`` + Two mutually exclusive modes are available and can be configured using the ``mode`` parameter of the constructor: .. rubric:: Hub mode - If the ``name`` takes the form of the URI ``hub:///``, the project - is configured to the ``hub`` mode to communicate with the ``skore hub``. + The project is configured to communicate with ``skore hub``. - A workspace is a ``skore hub`` concept that must be configured on the ``skore hub`` - interface. It represents an isolated entity managing users, projects, and - resources. It can be a company, organization, or team that operates + In this mode, ``name`` is expected to be of the form ``/``, where + the workspace is a ``skore hub`` concept that must be configured on the + ``skore hub`` interface. It represents an isolated entity managing users, projects, + and resources. It can be a company, organization, or team that operates independently within the system. - In this mode, you must have an account to the ``skore hub`` and must be - authorized to the specified workspace. You must also be authenticated beforehand, - by calling the ``skore.login()`` function at the top of your script. + Note: Using Project in ``hub`` mode requires an account on ``skore hub``, with + access rights to the specified workspace. Authentication to ``skore hub`` is done by + running ``skore.login()`` before instantiating the Project. .. rubric:: Local mode @@ -59,12 +62,9 @@ class Project: Parameters ---------- name : str - The name of the project: - - - if the ``name`` takes the form of the URI ``hub:///``, the - project is configured to communicate with the ``skore hub``, - - otherwise, the project is configured to communicate with a local storage, on - the user machine. + The name of the project. + mode : {"hub", "local"} + The mode of the project. **kwargs : dict Extra keyword arguments passed to the project, depending on its mode. @@ -74,9 +74,9 @@ class Project: Attributes ---------- name : str - The name of the project, extrapolated from the ``name`` parameter. - mode : str - The mode of the project, extrapolated from the ``name`` parameter. + The name of the project. + mode : {"hub", "local"} + The mode of the project. ml_task : MLTask The ML task of the project; unset until a first report is put. @@ -120,7 +120,7 @@ class Project: >>> from skore import Project >>> >>> tmpdir = TemporaryDirectory().name - >>> local_project = Project("my-xp", workspace=Path(tmpdir)) + >>> local_project = Project(mode="local", name="my-xp", workspace=Path(tmpdir)) Put reports in the project. @@ -139,39 +139,35 @@ class Project: Create a summary view to investigate persisted reports' metadata/metrics. """ - __HUB_NAME_PATTERN = re.compile(r"hub://(?P[^/]+)/(?P.+)") + __HUB_NAME_PATTERN = re.compile(r"(?P[^/]+)/(?P.+)") @staticmethod - def __setup_plugin(name: str) -> tuple[Literal["local", "hub"], str, Any, dict]: - PLUGINS = entry_points(group="skore.plugins.project") - mode: Literal["local", "hub"] - - if match := re.match(Project.__HUB_NAME_PATTERN, name): - mode = "hub" - name = match["name"] - parameters = {"workspace": match["workspace"], "name": name} - else: - mode = "local" - parameters = {"name": name} + def __setup_plugin(mode: ProjectMode, name: str) -> tuple[Any, dict]: + if mode == "hub": + if not (match := re.match(Project.__HUB_NAME_PATTERN, name)): + raise ValueError( + f'In hub mode, the name must be formatted as "/" ' + f"(found {name})" + ) - if mode not in PLUGINS.names: - raise ValueError(f"Unknown mode `{mode}`. Please install `skore[{mode}]`.") + parameters = {"workspace": match["workspace"], "name": match["name"]} + elif mode == "local": + parameters = {"name": name} + else: + raise ValueError(f'`mode` must be "hub" or "local" (found {mode})') - return mode, name, PLUGINS[mode].load(), parameters + return plugin.get(group="skore.plugins.project", mode=mode), parameters - def __init__(self, name: str, **kwargs): + def __init__(self, name: str, *, mode: ProjectMode = "local", **kwargs): r""" Initialize a project. Parameters ---------- name : str - The name of the project: - - - if the ``name`` takes the form of the URI ``hub:///``, - the project is configured to communicate with the ``skore hub``, - - otherwise, the project is configured to communicate with a local storage, - on the user machine. + The name of the project. + mode : {"hub", "local"}, default "local" + The mode of the project. **kwargs : dict Extra keyword arguments passed to the project, depending on its mode. @@ -188,7 +184,7 @@ def __init__(self, name: str, **kwargs): - on Linux, usually ``${HOME}/.cache/skore``, - on macOS, usually ``${HOME}/Library/Caches/skore``. """ - mode, name, plugin, parameters = Project.__setup_plugin(name) + plugin, parameters = Project.__setup_plugin(mode, name) self.__mode = mode self.__name = name @@ -233,6 +229,8 @@ def put(self, key: str, report: EstimatorReport | CrossValidationReport): TypeError If the combination of parameters are not valid. """ + from skore import CrossValidationReport, EstimatorReport + if not isinstance(key, str): raise TypeError(f"Key must be a string (found '{type(key)}')") @@ -282,19 +280,16 @@ def __repr__(self) -> str: # noqa: D105 return self.__project.__repr__() @staticmethod - def delete(name: str, **kwargs): + def delete(name: str, *, mode: ProjectMode = "local", **kwargs): r""" Delete a project. Parameters ---------- name : str - The name of the project: - - - if the ``name`` takes the form of the URI ``hub:///``, - the project is configured to communicate with the ``skore hub``, - - otherwise, the project is configured to communicate with a local storage, - on the user machine. + The name of the project. + mode : {"hub", "local"}, default "local" + The mode of the project. **kwargs : dict Extra keyword arguments passed to the project, depending on its mode. @@ -311,6 +306,6 @@ def delete(name: str, **kwargs): - on Linux, usually ``${HOME}/.cache/skore``, - on macOS, usually ``${HOME}/Library/Caches/skore``. """ - _, _, plugin, parameters = Project.__setup_plugin(name) + plugin, parameters = Project.__setup_plugin(mode, name) return plugin.delete(**(kwargs | parameters)) diff --git a/skore/src/skore/_project/types.py b/skore/src/skore/_project/types.py new file mode 100644 index 0000000000..b6fd89d2ba --- /dev/null +++ b/skore/src/skore/_project/types.py @@ -0,0 +1,4 @@ +from typing import Literal + +ProjectMode = Literal["hub", "local"] +PluginGroup = Literal["skore.plugins.project", "skore.plugins.login"] diff --git a/skore/tests/unit/test_login.py b/skore/tests/unit/project/test_login.py similarity index 88% rename from skore/tests/unit/test_login.py rename to skore/tests/unit/project/test_login.py index ea43b14ac7..0f6b43e096 100644 --- a/skore/tests/unit/test_login.py +++ b/skore/tests/unit/project/test_login.py @@ -20,7 +20,7 @@ def load(self): def test_login_local(monkeypatch, FakeLogin): monkeypatch.setattr( - "skore._login.entry_points", + "skore._project.plugin.entry_points", lambda **kwargs: EntryPoints( [ FakeEntryPoint( @@ -41,7 +41,7 @@ def test_login_local(monkeypatch, FakeLogin): def test_login_hub(monkeypatch, FakeLogin): monkeypatch.setattr( - "skore._login.entry_points", + "skore._project.plugin.entry_points", lambda **kwargs: EntryPoints( [ FakeEntryPoint( @@ -63,7 +63,9 @@ def test_login_hub(monkeypatch, FakeLogin): def test_login_hub_without_plugin(monkeypatch): - monkeypatch.setattr("skore._login.entry_points", lambda **kwargs: EntryPoints([])) + monkeypatch.setattr( + "skore._project.plugin.entry_points", lambda **kwargs: EntryPoints([]) + ) from skore import login diff --git a/skore/tests/unit/project/test_project.py b/skore/tests/unit/project/test_project.py index 4c0b5546c9..df34080f30 100644 --- a/skore/tests/unit/project/test_project.py +++ b/skore/tests/unit/project/test_project.py @@ -9,7 +9,7 @@ from sklearn.model_selection import train_test_split from skore import CrossValidationReport, EstimatorReport, Project -from skore.project._summary import Summary +from skore._project._summary import Summary class FakeEntryPoint(EntryPoint): @@ -36,7 +36,7 @@ def FakeHubProject(): @fixture(autouse=True) def monkeypatch_entrypoints(monkeypatch, FakeLocalProject, FakeHubProject): monkeypatch.setattr( - "skore.project.project.entry_points", + "skore._project.plugin.entry_points", lambda **kwargs: EntryPoints( [ FakeEntryPoint( @@ -93,7 +93,7 @@ def cv_regression() -> CrossValidationReport: class TestProject: def test_init_local(self, FakeLocalProject): - project = Project("", workspace="") + project = Project(mode="local", name="", workspace="") assert isinstance(project, Project) assert project._Project__mode == "local" @@ -108,21 +108,21 @@ def test_init_local(self, FakeLocalProject): def test_init_local_unknown_plugin(self, monkeypatch, tmp_path): monkeypatch.undo() monkeypatch.setattr( - "skore.project.project.entry_points", lambda **kwargs: EntryPoints([]) + "skore._project.plugin.entry_points", lambda **kwargs: EntryPoints([]) ) with raises( ValueError, match=escape("Unknown mode `local`. Please install `skore[local]`."), ): - Project("") + Project(mode="local", name="") def test_init_hub(self, FakeHubProject): - project = Project("hub:///") + project = Project(mode="hub", name="/") assert isinstance(project, Project) assert project._Project__mode == "hub" - assert project._Project__name == "" + assert project._Project__name == "/" assert FakeHubProject.called assert not FakeHubProject.call_args.args assert FakeHubProject.call_args.kwargs == { @@ -133,14 +133,14 @@ def test_init_hub(self, FakeHubProject): def test_init_hub_unknown_plugin(self, monkeypatch, tmp_path): monkeypatch.undo() monkeypatch.setattr( - "skore.project.project.entry_points", lambda **kwargs: EntryPoints([]) + "skore._project.plugin.entry_points", lambda **kwargs: EntryPoints([]) ) with raises( ValueError, match=escape("Unknown mode `hub`. Please install `skore[hub]`."), ): - Project("hub:///") + Project(mode="hub", name="/") def test_init_exception_wrong_ml_task(self, monkeypatch): """If the underlying Project implementation contains reports with @@ -156,7 +156,7 @@ def test_init_exception_wrong_ml_task(self, monkeypatch): project_factory = Mock(return_value=project) monkeypatch.setattr( - "skore.project.project.entry_points", + "skore._project.plugin.entry_points", lambda **kwargs: EntryPoints( [ FakeEntryPoint( @@ -173,15 +173,17 @@ def test_init_exception_wrong_ml_task(self, monkeypatch): "Got ML tasks " ) with raises(RuntimeError, match=err_msg): - Project("", workspace="") + Project(mode="local", name="", workspace="") def test_mode(self): - assert Project("").mode == "local" - assert Project("hub:///").mode == "hub" + assert Project(mode="local", name="").mode == "local" + assert Project(mode="hub", name="/").mode == "hub" def test_name(self): - assert Project("").name == "" - assert Project("hub:///").name == "" + assert Project(mode="local", name="").name == "" + assert ( + Project(mode="hub", name="/").name == "/" + ) @mark.parametrize( "report", @@ -192,7 +194,7 @@ def test_name(self): ) def test_put(self, report, FakeLocalProject, request): report = request.getfixturevalue(report) - project = Project("") + project = Project(mode="local", name="") project.put("", report) @@ -206,13 +208,13 @@ def test_put(self, report, FakeLocalProject, request): def test_put_exception(self): with raises(TypeError, match="Key must be a string"): - Project("").put(None, "") + Project(mode="local", name="").put(None, "") with raises(TypeError, match="Report must be `EstimatorReport` or"): - Project("").put("", "") + Project(mode="local", name="").put("", "") def test_put_exception_wrong_ml_task(self, regression, classification): - project = Project("", workspace="") + project = Project(mode="local", name="", workspace="") project.put("classification", classification) assert project.ml_task == "binary-classification" @@ -225,7 +227,7 @@ def test_put_exception_wrong_ml_task(self, regression, classification): project.put("regression", regression) def test_get(self, FakeLocalProject): - project = Project("") + project = Project(mode="local", name="") project.get("") @@ -235,7 +237,7 @@ def test_get(self, FakeLocalProject): assert not project._Project__project.get.call_args.kwargs def test_summarize(self): - project = Project("") + project = Project(mode="local", name="") project._Project__project.summarize.return_value = [ { "learner": "", @@ -289,7 +291,7 @@ def test_summarize_with_skore_local_project(self, monkeypatch, tmpdir): y_test=y_test, ) - project = Project("", workspace=Path(r"{tmpdir}")) + project = Project(mode="local", name="", workspace=Path(r"{tmpdir}")) project.put("", regression) project.summarize() """ @@ -301,11 +303,11 @@ def test_summarize_with_skore_local_project(self, monkeypatch, tmpdir): execution_result.raise_error() def test_repr(self): - project = Project("") + project = Project(mode="local", name="") assert repr(project) == repr(project._Project__project) def test_delete_local(self, FakeLocalProject): - Project.delete("", workspace="") + Project.delete(mode="local", name="", workspace="") assert not FakeLocalProject.called assert FakeLocalProject.delete.called @@ -316,7 +318,7 @@ def test_delete_local(self, FakeLocalProject): } def test_delete_hub(self, FakeHubProject): - Project.delete("hub:///") + Project.delete(mode="hub", name="/") assert not FakeHubProject.called assert FakeHubProject.delete.called diff --git a/skore/tests/unit/project/test_summary.py b/skore/tests/unit/project/test_summary.py index 5c0b54cc6f..f639a84c9d 100644 --- a/skore/tests/unit/project/test_summary.py +++ b/skore/tests/unit/project/test_summary.py @@ -9,8 +9,8 @@ from sklearn.linear_model import LinearRegression, LogisticRegression from sklearn.model_selection import train_test_split +from skore._project._summary import Summary from skore._sklearn import ComparisonReport, CrossValidationReport, EstimatorReport -from skore.project._summary import Summary @fixture @@ -208,7 +208,7 @@ def test_reports_filter_true( ] monkeypatch.setattr( - "skore.project._summary.Summary._query_string_selection", + "skore._project._summary.Summary._query_string_selection", lambda self: "ml_task == 'regression'", ) @@ -241,7 +241,7 @@ def test_reports_filter_false( ] monkeypatch.setattr( - "skore.project._summary.Summary._query_string_selection", + "skore._project._summary.Summary._query_string_selection", lambda self: "ml_task == 'regression'", ) @@ -436,7 +436,7 @@ def test_repr_mimebundle_fallback_without_jupyter_deps( ): """Without Jupyter deps, Summary shows table and warns instead of widget.""" monkeypatch.setattr( - "skore.project._summary._jupyter_dependencies_available", + "skore._project._summary._jupyter_dependencies_available", lambda: False, ) project = FakeProject( diff --git a/skore/tests/unit/project/test_widget.py b/skore/tests/unit/project/test_widget.py index 551a2df5ea..595f6e79e7 100644 --- a/skore/tests/unit/project/test_widget.py +++ b/skore/tests/unit/project/test_widget.py @@ -5,7 +5,7 @@ import plotly.graph_objects as go import pytest -from skore.project._widget import ModelExplorerWidget +from skore._project._widget import ModelExplorerWidget @pytest.fixture @@ -383,7 +383,7 @@ def test_model_explorer_widget_no_dataset_for_task(metadata, capsys): def test_model_explorer_widget_requires_jupyter_deps(metadata, monkeypatch): """ModelExplorerWidget raises ImportError when Jupyter deps are not installed.""" monkeypatch.setattr( - "skore.project._widget._jupyter_dependencies_available", + "skore._project._widget._jupyter_dependencies_available", lambda: False, ) with pytest.raises(ImportError, match="pip install skore"): diff --git a/sphinx/user_guide/project.rst b/sphinx/user_guide/project.rst index 7913f7e906..55bfceb5cc 100644 --- a/sphinx/user_guide/project.rst +++ b/sphinx/user_guide/project.rst @@ -7,10 +7,9 @@ Storing data science artifacts .. currentmodule:: skore `skore` provides a :class:`Project` class to store data science artifacts. The storage -is either local or remote, based on the value passed to the parameter `name` at the -initialization. When `name` is set to the form of the URI `hub:///`, -the project is configured to the `hub` mode to communicate with the `skore hub`. -Refer to the documentation of :class:`Project` for the detailed API. +is either local or remote, based on the value passed to the parameter `mode` at +initialization. When `mode` is set to `hub`, the project is configured to communicate +with `skore hub`. Refer to the documentation of :class:`Project` for the detailed API. Once a project is created, store :class:`EstimatorReport` via the method :meth:`Project.put`.