Skip to content
2 changes: 1 addition & 1 deletion examples/getting_started/plot_getting_started.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@
os.environ["SKORE_WORKSPACE"] = temp_dir.name
# sphinx_gallery_end_ignore

project = skore.Project("german_credit_classification")
project = skore.Project(mode="local", name="german_credit_classification")

# %%
# We store our reports with descriptive keys:
Expand Down
2 changes: 1 addition & 1 deletion skore/src/skore/_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
logger = getLogger(__name__)


def login(*, mode: Literal["local", "hub"] = "hub", **kwargs):
def login(*, mode: Literal["hub", "local"] = "hub", **kwargs):
"""
Login to the storage backend.

Expand Down
87 changes: 44 additions & 43 deletions skore/src/skore/project/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from skore._sklearn.types import MLTask
from skore.project._summary import Summary

Mode = Literal["hub", "local"]


class Project:
r"""
Expand All @@ -23,20 +25,20 @@ 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://<workspace>/<name>``, the project
is configured to the ``hub`` mode to communicate with the ``skore hub``.
The project is configured to the ``hub`` mode to communicate with the ``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
independently within the system.
In this mode, the ``name`` takes the form ``<workspace>/<name>``, 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
Also 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.

Expand All @@ -60,12 +62,9 @@ class Project:
Parameters
----------
name : str
The name of the project:

- if the ``name`` takes the form of the URI ``hub://<workspace>/<name>``, 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.

Expand All @@ -75,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.

Expand Down Expand Up @@ -121,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.

Expand All @@ -140,39 +139,38 @@ class Project:
Create a summary view to investigate persisted reports' metadata/metrics.
"""

__HUB_NAME_PATTERN = re.compile(r"hub://(?P<workspace>[^/]+)/(?P<name>.+)")
__HUB_NAME_PATTERN = re.compile(r"(?P<workspace>[^/]+)/(?P<name>.+)")

@staticmethod
def __setup_plugin(name: str) -> tuple[Literal["local", "hub"], str, Any, dict]:
def __setup_plugin(mode: Mode, name: str) -> tuple[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}
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 "<workspace>/<name>" '
f"(found {name})"
)

parameters = {"workspace": match["workspace"], "name": match["name"]}
else:
mode = "local"
parameters = {"name": name}

if mode not in PLUGINS.names:
raise ValueError(f"Unknown mode `{mode}`. Please install `skore[{mode}]`.")

return mode, name, PLUGINS[mode].load(), parameters
return PLUGINS[mode].load(), parameters

def __init__(self, name: str, **kwargs):
def __init__(self, name: str, *, mode: Mode, **kwargs):
r"""
Initialize a project.

Parameters
----------
name : str
The name of the project:

- if the ``name`` takes the form of the URI ``hub://<workspace>/<name>``,
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.

Expand All @@ -189,7 +187,10 @@ 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)
if mode not in ("hub", "local"):
raise ValueError(f'`mode` must be "hub" or "local" (found {mode})')

plugin, parameters = Project.__setup_plugin(mode, name)

self.__mode = mode
self.__name = name
Expand Down Expand Up @@ -281,19 +282,16 @@ def __repr__(self) -> str: # noqa: D105
return self.__project.__repr__()

@staticmethod
def delete(name: str, **kwargs):
def delete(name: str, *, mode: Mode, **kwargs):
r"""
Delete a project.

Parameters
----------
name : str
The name of the project:

- if the ``name`` takes the form of the URI ``hub://<workspace>/<name>``,
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.

Expand All @@ -310,6 +308,9 @@ def delete(name: str, **kwargs):
- on Linux, usually ``${HOME}/.cache/skore``,
- on macOS, usually ``${HOME}/Library/Caches/skore``.
"""
_, _, plugin, parameters = Project.__setup_plugin(name)
if mode not in ("hub", "local"):
raise ValueError(f'`mode` must be "hub" or "local" (found {mode})')

plugin, parameters = Project.__setup_plugin(mode, name)

return plugin.delete(**(kwargs | parameters))
42 changes: 22 additions & 20 deletions skore/tests/unit/project/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def cv_regression() -> CrossValidationReport:

class TestProject:
def test_init_local(self, FakeLocalProject):
project = Project("<name>", workspace="<workspace>")
project = Project(mode="local", name="<name>", workspace="<workspace>")

assert isinstance(project, Project)
assert project._Project__mode == "local"
Expand All @@ -115,14 +115,14 @@ def test_init_local_unknown_plugin(self, monkeypatch, tmp_path):
ValueError,
match=escape("Unknown mode `local`. Please install `skore[local]`."),
):
Project("<name>")
Project(mode="local", name="<name>")

def test_init_hub(self, FakeHubProject):
project = Project("hub://<workspace>/<name>")
project = Project(mode="hub", name="<workspace>/<name>")

assert isinstance(project, Project)
assert project._Project__mode == "hub"
assert project._Project__name == "<name>"
assert project._Project__name == "<workspace>/<name>"
assert FakeHubProject.called
assert not FakeHubProject.call_args.args
assert FakeHubProject.call_args.kwargs == {
Expand All @@ -140,7 +140,7 @@ def test_init_hub_unknown_plugin(self, monkeypatch, tmp_path):
ValueError,
match=escape("Unknown mode `hub`. Please install `skore[hub]`."),
):
Project("hub://<workspace>/<name>")
Project(mode="hub", name="<workspace>/<name>")

def test_init_exception_wrong_ml_task(self, monkeypatch):
"""If the underlying Project implementation contains reports with
Expand Down Expand Up @@ -173,15 +173,17 @@ def test_init_exception_wrong_ml_task(self, monkeypatch):
"Got ML tasks "
)
with raises(RuntimeError, match=err_msg):
Project("<name>", workspace="<workspace>")
Project(mode="local", name="<name>", workspace="<workspace>")

def test_mode(self):
assert Project("<name>").mode == "local"
assert Project("hub://<workspace>/<name>").mode == "hub"
assert Project(mode="local", name="<name>").mode == "local"
assert Project(mode="hub", name="<workspace>/<name>").mode == "hub"

def test_name(self):
assert Project("<name>").name == "<name>"
assert Project("hub://<workspace>/<name>").name == "<name>"
assert Project(mode="local", name="<name>").name == "<name>"
assert (
Project(mode="hub", name="<workspace>/<name>").name == "<workspace>/<name>"
)

@mark.parametrize(
"report",
Expand All @@ -192,7 +194,7 @@ def test_name(self):
)
def test_put(self, report, FakeLocalProject, request):
report = request.getfixturevalue(report)
project = Project("<name>")
project = Project(mode="local", name="<name>")

project.put("<key>", report)

Expand All @@ -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("<name>").put(None, "<value>")
Project(mode="local", name="<name>").put(None, "<value>")

with raises(TypeError, match="Report must be `EstimatorReport` or"):
Project("<name>").put("<key>", "<value>")
Project(mode="local", name="<name>").put("<key>", "<value>")

def test_put_exception_wrong_ml_task(self, regression, classification):
project = Project("<name>", workspace="<workspace>")
project = Project(mode="local", name="<name>", workspace="<workspace>")
project.put("classification", classification)
assert project.ml_task == "binary-classification"

Expand All @@ -224,7 +226,7 @@ def test_put_exception_wrong_ml_task(self, regression, classification):
project.put("regression", regression)

def test_get(self, FakeLocalProject):
project = Project("<name>")
project = Project(mode="local", name="<name>")

project.get("<id>")

Expand All @@ -234,7 +236,7 @@ def test_get(self, FakeLocalProject):
assert not project._Project__project.get.call_args.kwargs

def test_summarize(self):
project = Project("<name>")
project = Project(mode="local", name="<name>")
project._Project__project.summarize.return_value = [
{
"learner": "<learner>",
Expand Down Expand Up @@ -288,7 +290,7 @@ def test_summarize_with_skore_local_project(self, monkeypatch, tmpdir):
y_test=y_test,
)

project = Project("<project>", workspace=Path(r"{tmpdir}"))
project = Project(mode="local", name="<project>", workspace=Path(r"{tmpdir}"))
project.put("<report>", regression)
project.summarize()
"""
Expand All @@ -300,11 +302,11 @@ def test_summarize_with_skore_local_project(self, monkeypatch, tmpdir):
execution_result.raise_error()

def test_repr(self):
project = Project("<name>")
project = Project(mode="local", name="<name>")
assert repr(project) == repr(project._Project__project)

def test_delete_local(self, FakeLocalProject):
Project.delete("<name>", workspace="<workspace>")
Project.delete(mode="local", name="<name>", workspace="<workspace>")

assert not FakeLocalProject.called
assert FakeLocalProject.delete.called
Expand All @@ -315,7 +317,7 @@ def test_delete_local(self, FakeLocalProject):
}

def test_delete_hub(self, FakeHubProject):
Project.delete("hub://<workspace>/<name>")
Project.delete(mode="hub", name="<workspace>/<name>")

assert not FakeHubProject.called
assert FakeHubProject.delete.called
Expand Down