diff --git a/README.md b/README.md index 6ca79a705..b0f05e495 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ You can find information on the latest version [here](https://anaconda.org/conda 1. From your Python code, create and load a skore project: ```python import skore - my_project = skore.open("my_project", create=True) + my_project = skore.Project("my_project") ``` This will create a skore project directory named `my_project.skore` in your current working directory. diff --git a/examples/getting_started/plot_quick_start.py b/examples/getting_started/plot_quick_start.py index b0a7a55f4..96735aff1 100644 --- a/examples/getting_started/plot_quick_start.py +++ b/examples/getting_started/plot_quick_start.py @@ -21,7 +21,7 @@ temp_dir_path = Path(temp_dir.name) os.chdir(temp_dir_path) # sphinx_gallery_end_ignore -my_project = skore.open("my_project", create=True) +my_project = skore.Project("my_project") # %% # This will create a skore project directory named ``my_project.skore`` in your diff --git a/examples/getting_started/plot_skore_getting_started.py b/examples/getting_started/plot_skore_getting_started.py index 1cab1038c..f7fcd2e1b 100644 --- a/examples/getting_started/plot_skore_getting_started.py +++ b/examples/getting_started/plot_skore_getting_started.py @@ -49,7 +49,7 @@ temp_dir_path = Path(temp_dir.name) os.chdir(temp_dir_path) # sphinx_gallery_end_ignore -my_project = skore.open("my_project", create=True) +my_project = skore.Project("my_project") # %% # Now that the project exists, we can write some Python code (in the same diff --git a/examples/model_evaluation/plot_cross_validate.py b/examples/model_evaluation/plot_cross_validate.py index aa5dc5c11..8a15bbda5 100644 --- a/examples/model_evaluation/plot_cross_validate.py +++ b/examples/model_evaluation/plot_cross_validate.py @@ -31,7 +31,7 @@ temp_dir_path = Path(temp_dir.name) os.chdir(temp_dir_path) # sphinx_gallery_end_ignore -my_project = skore.open("my_project", create=True) +my_project = skore.Project("my_project") # %% diff --git a/examples/skore_project/plot_tracking_items.py b/examples/skore_project/plot_tracking_items.py index 818afd2a9..f0098ffb7 100644 --- a/examples/skore_project/plot_tracking_items.py +++ b/examples/skore_project/plot_tracking_items.py @@ -29,7 +29,7 @@ os.chdir(temp_dir_path) # sphinx_gallery_end_ignore -my_project = skore.open("my_project", create=True) +my_project = skore.Project("my_project") # %% # Tracking an integer diff --git a/examples/skore_project/plot_working_with_projects.py b/examples/skore_project/plot_working_with_projects.py index 4861f266d..79ba19caf 100644 --- a/examples/skore_project/plot_working_with_projects.py +++ b/examples/skore_project/plot_working_with_projects.py @@ -26,7 +26,7 @@ temp_dir_path = Path(temp_dir.name) os.chdir(temp_dir_path) # sphinx_gallery_end_ignore -my_project = skore.open("my_project", create=True) +my_project = skore.Project("my_project") # %% # Storing integers diff --git a/examples/use_cases/plot_employee_salaries.py b/examples/use_cases/plot_employee_salaries.py index dd083e7ab..95048ea00 100644 --- a/examples/use_cases/plot_employee_salaries.py +++ b/examples/use_cases/plot_employee_salaries.py @@ -37,7 +37,7 @@ temp_dir_path = Path(temp_dir.name) os.chdir(temp_dir_path) # sphinx_gallery_end_ignore -my_project = skore.open("my_project", create=True) +my_project = skore.Project("my_project") # %% # diff --git a/skore/pyproject.toml b/skore/pyproject.toml index 7ce75b8b5..fc6ab0cc9 100644 --- a/skore/pyproject.toml +++ b/skore/pyproject.toml @@ -46,7 +46,7 @@ Issues = "https://github.com/probabl-ai/skore/issues" "Release notes" = "https://github.com/probabl-ai/skore/releases" [project.scripts] -skore = "skore.__main__:main" +skore-ui = "skore.__main__:main" [build-system] requires = ["hatchling"] diff --git a/skore/src/skore/__init__.py b/skore/src/skore/__init__.py index 9e11fb796..cdf666230 100644 --- a/skore/src/skore/__init__.py +++ b/skore/src/skore/__init__.py @@ -5,7 +5,7 @@ from rich.console import Console from rich.theme import Theme -from skore.project import Project, open +from skore.project import Project from skore.sklearn import ( CrossValidationReport, CrossValidationReporter, @@ -19,7 +19,6 @@ "CrossValidationReporter", "CrossValidationReport", "EstimatorReport", - "open", "Project", "show_versions", "train_test_split", diff --git a/skore/src/skore/cli/cli.py b/skore/src/skore/cli/cli.py index 0a7631bf8..d33bb5379 100644 --- a/skore/src/skore/cli/cli.py +++ b/skore/src/skore/cli/cli.py @@ -4,33 +4,35 @@ from importlib.metadata import version from skore.cli.color_format import ColorArgumentParser -from skore.cli.launch_dashboard import __launch -from skore.cli.quickstart_command import __quickstart -from skore.project.create import _create +from skore.ui.launch import launch -def cli(args: list[str]): - """CLI for Skore.""" - parser = ColorArgumentParser(prog="skore") +def argumentparser(): + """Argument parser for the Skore CLI.""" + parser = ColorArgumentParser( + prog="skore-ui", + description="Launch the skore UI on a defined skore project.", + ) parser.add_argument( - "--version", action="version", version=f"%(prog)s {version('skore')}" + "--version", + action="version", + version=f"%(prog)s {version('skore')}", ) - subparsers = parser.add_subparsers(dest="subcommand") - - parser_launch = subparsers.add_parser("launch", help="Launch the web UI") - parser_launch.add_argument( + parser.add_argument( "project_name", - help="the name or path of the project to open", + help="the name or path of the project to be created or opened", ) - parser_launch.add_argument( + + parser.add_argument( "--port", type=int, help="the port at which to bind the UI server (default: %(default)s)", default=22140, ) - parser_launch.add_argument( + + parser.add_argument( "--open-browser", action=argparse.BooleanOptionalAction, help=( @@ -39,87 +41,24 @@ def cli(args: list[str]): ), default=True, ) - parser_launch.add_argument( - "--verbose", - action="store_true", - help="increase logging verbosity", - ) - parser_create = subparsers.add_parser("create", help="Create a project") - parser_create.add_argument( - "project_name", - nargs="?", - help="the name or path of the project to create (default: %(default)s)", - default="project", - ) - parser_create.add_argument( - "--overwrite", - action="store_true", - help="overwrite an existing project with the same name", - ) - parser_create.add_argument( + parser.add_argument( "--verbose", action="store_true", help="increase logging verbosity", ) - parser_quickstart = subparsers.add_parser( - "quickstart", help='Create a "project.skore" file and start the UI' - ) - parser_quickstart.add_argument( - "project_name", - nargs="?", - help="the name or path of the project to create (default: %(default)s)", - default="project", - ) - parser_quickstart.add_argument( - "--overwrite", - action="store_true", - help="overwrite an existing project with the same name", - ) - parser_quickstart.add_argument( - "--port", - type=int, - help="the port at which to bind the UI server (default: %(default)s)", - default=22140, - ) - parser_quickstart.add_argument( - "--open-browser", - action=argparse.BooleanOptionalAction, - help=( - "whether to automatically open a browser tab showing the web UI " - "(default: %(default)s)" - ), - default=True, - ) - parser_quickstart.add_argument( - "--verbose", - action="store_true", - help="increase logging verbosity", - ) + return parser - parsed_args: argparse.Namespace = parser.parse_args(args) - if parsed_args.subcommand == "launch": - __launch( - project_name=parsed_args.project_name, - port=parsed_args.port, - open_browser=parsed_args.open_browser, - verbose=parsed_args.verbose, - ) - elif parsed_args.subcommand == "create": - _create( - project_name=parsed_args.project_name, - overwrite=parsed_args.overwrite, - verbose=parsed_args.verbose, - ) - elif parsed_args.subcommand == "quickstart": - __quickstart( - project_name=parsed_args.project_name, - overwrite=parsed_args.overwrite, - port=parsed_args.port, - open_browser=parsed_args.open_browser, - verbose=parsed_args.verbose, - ) - else: - parser.print_help() +def cli(args: list[str]): + """CLI for Skore.""" + parser = argumentparser() + arguments = parser.parse_args(args) + + launch( + project_name=arguments.project_name, + port=arguments.port, + open_browser=arguments.open_browser, + verbose=arguments.verbose, + ) diff --git a/skore/src/skore/cli/color_format.py b/skore/src/skore/cli/color_format.py index db510d649..4e39a1a84 100644 --- a/skore/src/skore/cli/color_format.py +++ b/skore/src/skore/cli/color_format.py @@ -104,3 +104,13 @@ def print_help(self, file=None): """Print the help message.""" console = Console(file=file) console.print(self.format_help()) + + def error(self, message): + """Print error message with Rich formatting and exit.""" + console = Console(stderr=True, theme=skore_console_theme) + console.print(f"[red bold]error:[/red bold] {message}") + + console.print(f"\n{self.format_usage()}") + + console.print("\nFor more information, try '[cyan]--help[/cyan]'") + self.exit(2) diff --git a/skore/src/skore/cli/quickstart_command.py b/skore/src/skore/cli/quickstart_command.py deleted file mode 100644 index e2aa0c394..000000000 --- a/skore/src/skore/cli/quickstart_command.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Implement the "quickstart" command.""" - -from pathlib import Path -from typing import Union - -from skore.cli import logger -from skore.cli.launch_dashboard import __launch -from skore.project.create import _create -from skore.utils._logger import logger_context - - -def __quickstart( - project_name: Union[str, Path], - overwrite: bool, - port: int, - open_browser: bool, - verbose: bool = False, -): - """Quickstart a Skore project. - - Create it if it does not exist, then launch the web UI. - - Parameters - ---------- - project_name : Path-like - Name of the project to be created, or a relative or absolute path. - overwrite : bool - If ``True``, overwrite an existing project with the same name. - If ``False``, simply warn that a project already exists. - port : int - Port at which to bind the UI server. - open_browser : bool - Whether to automatically open a browser tab showing the UI. - verbose : bool - Whether to increase logging verbosity. - """ - with logger_context(logger, verbose): - try: - _create( - project_name=project_name, - overwrite=overwrite, - verbose=verbose, - ) - except FileExistsError: - logger.info( - f"Project file '{project_name}' already exists. Skipping creation step." - ) - - path = Path(project_name) - - __launch( - project_name=path, - port=port, - open_browser=open_browser, - verbose=verbose, - ) diff --git a/skore/src/skore/persistence/view/__init__.py b/skore/src/skore/persistence/view/__init__.py index 18ffb934f..49dce81a6 100644 --- a/skore/src/skore/persistence/view/__init__.py +++ b/skore/src/skore/persistence/view/__init__.py @@ -1 +1,5 @@ """Implement view primitives and storage.""" + +from .view import View + +__all__ = ["View"] diff --git a/skore/src/skore/project/__init__.py b/skore/src/skore/project/__init__.py index be1978581..6f8fb08a5 100644 --- a/skore/src/skore/project/__init__.py +++ b/skore/src/skore/project/__init__.py @@ -1,9 +1,7 @@ """Alias top level function and class of the project submodule.""" -from .open import open from .project import Project __all__ = [ - "open", "Project", ] diff --git a/skore/src/skore/project/create.py b/skore/src/skore/project/create.py deleted file mode 100644 index fa6eac7f2..000000000 --- a/skore/src/skore/project/create.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Create project helper.""" - -import re -import shutil -from pathlib import Path -from typing import Optional, Union - -from skore.exceptions import ( - InvalidProjectNameError, - ProjectCreationError, - ProjectPermissionError, -) -from skore.persistence.view.view import View -from skore.project.load import _load -from skore.project.project import Project, logger -from skore.utils._logger import logger_context - - -def _validate_project_name(project_name: str) -> tuple[bool, Optional[Exception]]: - """Validate the project name (the part before ".skore"). - - Returns `(True, None)` if validation succeeded and `(False, Exception(...))` - otherwise. - """ - # The project name (including the .skore extension) must be between 5 and 255 - # characters long. - # FIXME: On Linux the OS already checks filename lengths - if len(project_name) + len(".skore") > 255: - return False, InvalidProjectNameError( - "Project name length cannot exceed 255 characters." - ) - - # Reserved Names: The following names are reserved and cannot be used: - # CON, PRN, AUX, NUL - # COM1, COM2, COM3, COM4, COM5, COM6, COM7, COM8, COM9 - # LPT1, LPT2, LPT3, LPT4, LPT5, LPT6, LPT7, LPT8, LPT9 - reserved_patterns = "|".join(["CON", "PRN", "AUX", "NUL", r"COM\d+", r"LPT\d+"]) - if re.fullmatch(f"^({reserved_patterns})$", project_name): - return False, InvalidProjectNameError( - "Project name must not be a reserved OS filename." - ) - - # Allowed Characters: - # Alphanumeric characters (a-z, A-Z, 0-9) - # Underscore (_) - # Hyphen (-) - # Starting Character: The project name must start with an alphanumeric character. - if not re.fullmatch(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", project_name): - return False, InvalidProjectNameError( - "Project name must contain only alphanumeric characters, '_' and '-'." - ) - - # Case Sensitivity: File names are case-insensitive on Windows and case-sensitive - # on Unix-based systems. The CLI should warn users about potential case conflicts - # on Unix systems. - - return True, None - - -def _create( - project_name: Union[str, Path], - overwrite: bool = False, - verbose: bool = False, -) -> Project: - """Create a project file named according to ``project_name``. - - Parameters - ---------- - project_name : Path-like - Name of the project to be created, or a relative or absolute path. If relative, - will be interpreted as relative to the current working directory. - overwrite : bool - If ``True``, overwrite an existing project with the same name. - If ``False``, raise an error if a project with the same name already exists. - verbose : bool - Whether or not to display info logs to the user. - - Returns - ------- - The created project - """ - from skore import console # avoid circular import - - with logger_context(logger, verbose): - project_path = Path(project_name) - - # Remove trailing ".skore" if it exists to check the name is valid - checked_project_name: str = project_path.name.split(".skore")[0] - - validation_passed, validation_error = _validate_project_name( - checked_project_name - ) - if not validation_passed: - raise ProjectCreationError( - f"Unable to create project file '{project_path}'." - ) from validation_error - - # The file must end with the ".skore" extension. - # If not provided, it will be automatically appended. - # If project name is an absolute path, we keep that path - - project_directory = project_path.with_name(checked_project_name + ".skore") - - if project_directory.exists(): - if not overwrite: - raise FileExistsError( - f"Unable to create project file '{project_directory}' because a " - "file with that name already exists. Please choose a different " - "name or use the --overwrite flag with the CLI or overwrite=True " - "with the API." - ) - shutil.rmtree(project_directory) - - try: - project_directory.mkdir(parents=True) - except PermissionError as e: - raise ProjectPermissionError( - f"Unable to create project file '{project_directory}'. " - "Please check your permissions for the current directory." - ) from e - except Exception as e: - raise ProjectCreationError( - f"Unable to create project file '{project_directory}'." - ) from e - - # Once the main project directory has been created, created the nested - # directories - - items_dir = project_directory / "items" - try: - items_dir.mkdir() - except Exception as e: - raise ProjectCreationError( - f"Unable to create project file '{items_dir}'." - ) from e - - views_dir = project_directory / "views" - try: - views_dir.mkdir() - except Exception as e: - raise ProjectCreationError( - f"Unable to create project file '{views_dir}'." - ) from e - - p = _load(project_directory) - p.view_repository.put_view("default", View(layout=[])) - - console.rule("[bold cyan]skore[/bold cyan]") - console.print(f"Project file '{project_directory}' was successfully created.") - return p diff --git a/skore/src/skore/project/load.py b/skore/src/skore/project/load.py deleted file mode 100644 index 1c94980e9..000000000 --- a/skore/src/skore/project/load.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Load project helper.""" - -from pathlib import Path -from typing import Union - -from skore.persistence.repository import ItemRepository, ViewRepository -from skore.persistence.storage.disk_cache_storage import ( - DirectoryDoesNotExist, - DiskCacheStorage, -) -from skore.project.project import Project - - -class ProjectLoadError(Exception): - """Failed to load project.""" - - -def _load(project_name: Union[str, Path]) -> Project: - """Load an existing Project given a project name or path. - - Transforms a project name to a directory path as follows: - - Resolves relative path to current working directory, - - Checks that the file ends with the ".skore" extension, - - If not provided, it will be automatically appended, - - If project name is an absolute path, keeps that path. - """ - path = Path(project_name).resolve() - - if path.suffix != ".skore": - path = path.parent / (path.name + ".skore") - - if not Path(path).exists(): - raise FileNotFoundError(f"Project '{path}' does not exist: did you create it?") - - try: - # FIXME: Should those hardcoded strings be factorized somewhere ? - item_storage = DiskCacheStorage(directory=Path(path) / "items") - item_repository = ItemRepository(storage=item_storage) - view_storage = DiskCacheStorage(directory=Path(path) / "views") - view_repository = ViewRepository(storage=view_storage) - project = Project( - item_repository=item_repository, - view_repository=view_repository, - ) - except DirectoryDoesNotExist as e: - missing_directory = e.args[0].split()[1] - raise ProjectLoadError( - f"Project '{path}' is corrupted: " - f"directory '{missing_directory}' should exist. " - "Consider re-creating the project." - ) from e - - return project diff --git a/skore/src/skore/project/open.py b/skore/src/skore/project/open.py deleted file mode 100644 index ad658b5e8..000000000 --- a/skore/src/skore/project/open.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Command to open a Project.""" - -from pathlib import Path -from typing import Union - -from skore.project.create import _create -from skore.project.load import _load -from skore.project.project import Project - - -def open( - project_path: Union[str, Path] = "project.skore", - *, - create: bool = True, - overwrite: bool = False, -) -> Project: - """Open a project given a project name or path. - - This function : - - opens the project if it already exists, - - creates the project if it does not exist, - - and creates by overwriting a pre-existing project if ``overwrite`` is set to - ``True``. - - Parameters - ---------- - project_path: Path-like, default="project.skore" - The relative or absolute path of the project. - create: bool, default=True - Whether or not to create the project, if it does not already exist. - overwrite: bool, default=False - Overwrite the project file if it already exists and ``create`` is ``True``. - Has no effect otherwise. - - Returns - ------- - Project - The opened Project instance. - - Raises - ------ - FileNotFoundError - If path is not found and ``create`` is set to ``False`` - ProjectCreationError - If project creation fails for some reason (e.g. if the project path is invalid) - """ - if create and not overwrite: - try: - return _load(project_path) - except FileNotFoundError: - return _create(project_path, overwrite=overwrite) - - if not create: - return _load(project_path) - - return _create(project_path, overwrite=overwrite) diff --git a/skore/src/skore/project/project.py b/skore/src/skore/project/project.py index c91dce954..4bb5226c5 100644 --- a/skore/src/skore/project/project.py +++ b/skore/src/skore/project/project.py @@ -2,27 +2,24 @@ from __future__ import annotations -import logging from collections.abc import Iterator -from typing import TYPE_CHECKING, Any, Literal, Optional, Union +from logging import INFO, NullHandler, getLogger +from pathlib import Path +from typing import Any, Literal, Optional, Union from skore.persistence.item import item_to_object, object_to_item +from skore.persistence.repository import ItemRepository, ViewRepository +from skore.persistence.storage import DiskCacheStorage +from skore.persistence.view import View -if TYPE_CHECKING: - from skore.persistence import ( - ItemRepository, - ViewRepository, - ) - - -logger = logging.getLogger(__name__) -logger.addHandler(logging.NullHandler()) # Default to no output -logger.setLevel(logging.INFO) +logger = getLogger(__name__) +logger.addHandler(NullHandler()) # Default to no output +logger.setLevel(INFO) class Project: """ - A collection of items arranged in views and stored in a storage. + A collection of items persisted in a storage. Its main methods are :func:`~skore.Project.put` and :func:`~skore.Project.get`, respectively to insert a key-value pair into the Project and to recover the value @@ -32,15 +29,84 @@ class Project: the persistency is based on the pickle representation. You must therefore ensure that the call to :func:`~skore.Project.get` is made in the same environment as :func:`~skore.Project.put`. + + Parameters + ---------- + path : str or Path, optional + The path of the project to initialize, default "./project.skore". + if_exists: Literal["raise", "load"], optional + Raise an exception if the project already exists, or load it, default raise. + + Attributes + ---------- + path : Path + The unified path of the project. + + Examples + -------- + >>> + >> import skore + >> + >> project = skore.Project("my-xp") + >> project.put("score", 1.0) + >> project.get("score") + 1.0 """ def __init__( self, - item_repository: ItemRepository, - view_repository: ViewRepository, + path: Optional[Union[str, Path]] = "project.skore", + *, + if_exists: Optional[Literal["raise", "load"]] = "raise", ): - self.item_repository = item_repository - self.view_repository = view_repository + """ + Initialize a Project. + + Initialize a project, by creating a new project or by loading an existing one. + + Parameters + ---------- + path : str or Path, optional + The path of the project to initialize, default "./project.skore". + if_exists: Literal["raise", "load"], optional + Raise an exception if the project already exists, or load it, default raise. + + Raises + ------ + FileExistsError + """ + self.path = Path(path) + self.path = self.path.with_suffix(".skore") + self.path = self.path.resolve() + + if if_exists == "raise" and self.path.exists(): + raise FileExistsError(f"Project '{str(path)}' already exists.") + + item_storage_dirpath = self.path / "items" + view_storage_dirpath = self.path / "views" + + # Create diskcache directories + item_storage_dirpath.mkdir(parents=True, exist_ok=True) + view_storage_dirpath.mkdir(parents=True, exist_ok=True) + + # Initialize repositories with dedicated storages + self._item_repository = ItemRepository(DiskCacheStorage(item_storage_dirpath)) + self._view_repository = ViewRepository(DiskCacheStorage(view_storage_dirpath)) + + # Ensure default view is available + if "default" not in self._view_repository: + self._view_repository.put_view("default", View(layout=[])) + + def clear(self): + """Clear the project.""" + for item_key in self._item_repository: + self._item_repository.delete_item(item_key) + + for view_key in self._view_repository: + self._view_repository.delete_view(view_key) + + # Ensure default view is available + self._view_repository.put_view("default", View(layout=[])) def put( self, @@ -78,7 +144,7 @@ def put( if not isinstance(key, str): raise TypeError(f"Key must be a string (found '{type(key)}')") - self.item_repository.put_item( + self._item_repository.put_item( key, object_to_item( value, @@ -140,11 +206,11 @@ def dto(item): } if version == -1: - return dto(self.item_repository.get_item(key)) + return dto(self._item_repository.get_item(key)) if version == "all": - return list(map(dto, self.item_repository.get_item_versions(key))) + return list(map(dto, self._item_repository.get_item_versions(key))) if isinstance(version, int): - return dto(self.item_repository.get_item_versions(key)[version]) + return dto(self._item_repository.get_item_versions(key)[version]) raise ValueError('`version` should be -1, "all", or an integer') @@ -157,7 +223,7 @@ def keys(self) -> list[str]: list[str] A list of all keys. """ - return self.item_repository.keys() + return self._item_repository.keys() def __iter__(self) -> Iterator[str]: """ @@ -168,7 +234,7 @@ def __iter__(self) -> Iterator[str]: Iterator[str] An iterator yielding all keys. """ - yield from self.item_repository + yield from self._item_repository def delete(self, key: str): """Delete the item corresponding to ``key`` from the Project. @@ -183,7 +249,7 @@ def delete(self, key: str): KeyError If the key does not correspond to any item. """ - self.item_repository.delete_item(key) + self._item_repository.delete_item(key) def set_note(self, key: str, note: str, *, version=-1): """Attach a note to key ``key``. @@ -213,7 +279,7 @@ def set_note(self, key: str, note: str, *, version=-1): # Annotate first version of key "key" >>> project.set_note("key", "note", version=0) # doctest: +SKIP """ - return self.item_repository.set_item_note(key=key, note=note, version=version) + return self._item_repository.set_item_note(key=key, note=note, version=version) def get_note(self, key: str, *, version=-1) -> Union[str, None]: """Retrieve a note previously attached to key ``key``. @@ -243,7 +309,7 @@ def get_note(self, key: str, *, version=-1) -> Union[str, None]: # Retrieve note attached to first version of key "key" >>> project.get_note("key", version=0) # doctest: +SKIP """ - return self.item_repository.get_item_note(key=key, version=version) + return self._item_repository.get_item_note(key=key, version=version) def delete_note(self, key: str, *, version=-1): """Delete a note previously attached to key ``key``. @@ -271,4 +337,4 @@ def delete_note(self, key: str, *, version=-1): # Delete note attached to first version of key "key" >>> project.delete_note("key", version=0) # doctest: +SKIP """ - return self.item_repository.delete_item_note(key=key, version=version) + return self._item_repository.delete_item_note(key=key, version=version) diff --git a/skore/src/skore/ui/app.py b/skore/src/skore/ui/app.py index df7ef64c3..82ec95b26 100644 --- a/skore/src/skore/ui/app.py +++ b/skore/src/skore/ui/app.py @@ -10,21 +10,14 @@ from fastapi.staticfiles import StaticFiles from starlette.types import Lifespan -from skore.project import Project, open +from skore.project import Project from skore.ui.dependencies import get_static_path from skore.ui.project_routes import router as project_router -def create_app( - project: Optional[Project] = None, lifespan: Optional[Lifespan] = None -) -> FastAPI: +def create_app(project: Project, lifespan: Optional[Lifespan] = None) -> FastAPI: """FastAPI factory used to create the API to interact with `stores`.""" app = FastAPI(lifespan=lifespan) - - # Give the app access to the project - if not project: - project = open("project.skore") - app.state.project = project # Enable CORS support on all routes, for all origins and methods. diff --git a/skore/src/skore/cli/launch_dashboard.py b/skore/src/skore/ui/launch.py similarity index 91% rename from skore/src/skore/cli/launch_dashboard.py rename to skore/src/skore/ui/launch.py index 368838c81..5911e1279 100644 --- a/skore/src/skore/cli/launch_dashboard.py +++ b/skore/src/skore/ui/launch.py @@ -1,4 +1,4 @@ -"""Implement the "launch" command.""" +"""Implement the "launch" function.""" import webbrowser from contextlib import asynccontextmanager @@ -9,12 +9,12 @@ from fastapi import FastAPI from skore.cli import logger -from skore.project import open +from skore.project import Project from skore.ui.app import create_app from skore.utils._logger import logger_context -def __launch( +def launch( project_name: Union[str, Path], port: int, open_browser: bool, @@ -36,7 +36,7 @@ def __launch( from skore import console # avoid circular import with logger_context(logger, verbose): - project = open(project_name) + project = Project(project_name, if_exists="load") @asynccontextmanager async def lifespan(app: FastAPI): diff --git a/skore/src/skore/ui/project_routes.py b/skore/src/skore/ui/project_routes.py index bf104a80f..435b273d2 100644 --- a/skore/src/skore/ui/project_routes.py +++ b/skore/src/skore/ui/project_routes.py @@ -53,14 +53,14 @@ def __project_as_serializable(project: Project) -> SerializableProject: items = { key: [ __item_as_serializable(key, item) - for item in project.item_repository.get_item_versions(key) + for item in project._item_repository.get_item_versions(key) ] - for key in project.item_repository + for key in project._item_repository } views = { - key: project.view_repository.get_view(key).layout - for key in project.view_repository + key: project._view_repository.get_view(key).layout + for key in project._view_repository } return SerializableProject( @@ -85,7 +85,7 @@ async def put_view(request: Request, key: str, layout: Layout): project: Project = request.app.state.project view = View(layout=layout) - project.view_repository.put_view(key, view) + project._view_repository.put_view(key, view) return __project_as_serializable(project) @@ -96,7 +96,7 @@ async def delete_view(request: Request, key: str): project: Project = request.app.state.project try: - project.view_repository.delete_view(key) + project._view_repository.delete_view(key) except KeyError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="View not found" @@ -119,8 +119,8 @@ async def get_activity( return sorted( ( __item_as_serializable(key, version) - for key in project.item_repository - for version in project.item_repository.get_item_versions(key) + for key in project._item_repository + for version in project._item_repository.get_item_versions(key) if datetime.fromisoformat(version.updated_at) > after ), key=operator.attrgetter("updated_at"), diff --git a/skore/tests/conftest.py b/skore/tests/conftest.py index 01348e0ac..993575a02 100644 --- a/skore/tests/conftest.py +++ b/skore/tests/conftest.py @@ -1,7 +1,6 @@ from datetime import datetime, timezone import pytest -import skore from skore.persistence.repository import ItemRepository, ViewRepository from skore.persistence.storage import InMemoryStorage from skore.project import Project @@ -37,19 +36,20 @@ def now(*args, **kwargs): @pytest.fixture -def in_memory_project(): - item_repository = ItemRepository(storage=InMemoryStorage()) - view_repository = ViewRepository(storage=InMemoryStorage()) - return Project( - item_repository=item_repository, - view_repository=view_repository, - ) +def in_memory_project(monkeypatch): + monkeypatch.delattr("skore.project.Project.__init__") + + project = Project() + project.path = None + project._item_repository = ItemRepository(storage=InMemoryStorage()) + project._view_repository = ViewRepository(storage=InMemoryStorage()) + + return project @pytest.fixture def on_disk_project(tmp_path): - project = skore.open(tmp_path / "project") - return project + return Project(tmp_path / "project.skore") @pytest.fixture(scope="function") diff --git a/skore/tests/integration/cli/test_cli.py b/skore/tests/integration/cli/test_cli.py deleted file mode 100644 index 732a582ca..000000000 --- a/skore/tests/integration/cli/test_cli.py +++ /dev/null @@ -1,33 +0,0 @@ -import subprocess -from importlib.metadata import version - -import pytest - - -def test_no_subcommand(): - """If the CLI is given no subcommand, it should output the help menu.""" - completed_process = subprocess.run(["skore"]) - - completed_process.check_returncode() - - -def test_invalid_subcommand(): - """If the CLI is given an invalid subcommand, - it should exit and warn that the subcommand is invalid.""" - completed_process = subprocess.run( - ["skore", "probabl-wrong-command"], capture_output=True - ) - - with pytest.raises(subprocess.CalledProcessError): - completed_process.check_returncode() - - assert b"invalid" in completed_process.stderr - assert b"probabl-wrong-command" in completed_process.stderr - - -def test_version(): - """The --version command should not fail.""" - completed_process = subprocess.run(["skore", "--version"], capture_output=True) - - completed_process.check_returncode() - assert f'skore {version("skore")}'.encode() in completed_process.stdout diff --git a/skore/tests/integration/cli/test_create.py b/skore/tests/integration/cli/test_create.py deleted file mode 100644 index 6e249b389..000000000 --- a/skore/tests/integration/cli/test_create.py +++ /dev/null @@ -1,43 +0,0 @@ -import os - -import pytest -from skore.cli.cli import cli -from skore.exceptions import ProjectCreationError - - -def test_create_project_cli_default_argument(tmp_path): - os.chdir(tmp_path) - cli("create".split()) - assert (tmp_path / "project.skore").exists() - - -def test_create_project_cli_absolute_path(tmp_path): - os.chdir(tmp_path) - cli(f"create {tmp_path / 'hello.skore'}".split()) - assert (tmp_path / "hello.skore").exists() - - -def test_create_project_cli_ends_in_skore(tmp_path): - os.chdir(tmp_path) - cli("create hello.skore".split()) - assert (tmp_path / "hello.skore").exists() - - -def test_create_project_cli_invalid_name(): - with pytest.raises(ProjectCreationError): - cli("create hello.txt".split()) - - -def test_create_project_cli_overwrite(tmp_path): - """Check the behaviour of the `overwrite` flag/parameter.""" - os.chdir(tmp_path) - cli("create".split()) - assert (tmp_path / "project.skore").exists() - - # calling the same command without overwriting should fail - with pytest.raises(FileExistsError): - cli("create".split()) - - # calling the same command with overwriting should succeed - cli("create --overwrite".split()) - assert (tmp_path / "project.skore").exists() diff --git a/skore/tests/integration/cli/test_quickstart.py b/skore/tests/integration/cli/test_quickstart.py deleted file mode 100644 index ee2eb26a3..000000000 --- a/skore/tests/integration/cli/test_quickstart.py +++ /dev/null @@ -1,25 +0,0 @@ -import os - -import pytest -from skore.cli.cli import cli - - -@pytest.fixture -def fake_launch(monkeypatch): - def _fake_launch(project_name, port, open_browser, verbose): - pass - - monkeypatch.setattr("skore.cli.quickstart_command.__launch", _fake_launch) - - -def test_quickstart(tmp_path, fake_launch): - os.chdir(tmp_path) - cli("quickstart".split()) - assert (tmp_path / "project.skore").exists() - - # calling the same command without overwriting should succeed - # (as the creation step is skipped) - cli("quickstart".split()) - - # calling the same command with overwriting should succeed - cli("quickstart --overwrite".split()) diff --git a/skore/tests/integration/sklearn/test_cross_validate.py b/skore/tests/integration/sklearn/test_cross_validate.py index 00758f751..465d18f04 100644 --- a/skore/tests/integration/sklearn/test_cross_validate.py +++ b/skore/tests/integration/sklearn/test_cross_validate.py @@ -200,7 +200,7 @@ def test_cross_validation_reporter(in_memory_project, fixture_name, request): in_memory_project.put("cross-validation", reporter) - retrieved_item = in_memory_project.item_repository.get_item("cross-validation") + retrieved_item = in_memory_project._item_repository.get_item("cross-validation") assert isinstance(retrieved_item, CrossValidationReporterItem) diff --git a/skore/tests/integration/ui/test_ui.py b/skore/tests/integration/ui/test_ui.py index 07b766f94..0489db4cb 100644 --- a/skore/tests/integration/ui/test_ui.py +++ b/skore/tests/integration/ui/test_ui.py @@ -42,7 +42,7 @@ def test_get_items(client, in_memory_project): in_memory_project.put("test", "version_1") in_memory_project.put("test", "version_2") - items = in_memory_project.item_repository.get_item_versions("test") + items = in_memory_project._item_repository.get_item_versions("test") response = client.get("/api/project/items") assert response.status_code == 200 @@ -70,7 +70,7 @@ def test_put_view_layout(client): def test_delete_view(client, in_memory_project): - in_memory_project.view_repository.put_view("hello", View(layout=[])) + in_memory_project._view_repository.put_view("hello", View(layout=[])) response = client.delete("/api/project/views?key=hello") assert response.status_code == 202 diff --git a/skore/tests/unit/cli/test_cli.py b/skore/tests/unit/cli/test_cli.py index de72e4f01..5cb9ea7fe 100644 --- a/skore/tests/unit/cli/test_cli.py +++ b/skore/tests/unit/cli/test_cli.py @@ -1,10 +1,10 @@ -"""Test CLI properly calls the app.""" - import pytest from skore.cli.cli import cli -def test_cli_launch(monkeypatch): +def test_cli(monkeypatch, tmp_path): + """cli passes its arguments down to `launch`.""" + launch_project_name = None launch_port = None launch_open_browser = None @@ -21,15 +21,24 @@ def fake_launch(project_name, port, open_browser, verbose): launch_open_browser = open_browser launch_verbose = verbose - monkeypatch.setattr("skore.cli.cli.__launch", fake_launch) - cli(["launch", "project.skore", "--port", "0", "--no-open-browser", "--verbose"]) + monkeypatch.setattr("skore.cli.cli.launch", fake_launch) + + cli( + [ + str(tmp_path / "my_project.skore"), + "--port", + "888", + "--no-open-browser", + "--verbose", + ] + ) - assert launch_project_name == "project.skore" - assert launch_port == 0 - assert not launch_open_browser - assert launch_verbose + assert launch_project_name == str(tmp_path / "my_project.skore") + assert launch_port == 888 + assert launch_open_browser is False + assert launch_verbose is True -def test_cli_launch_no_project_name(): +def test_cli_no_project_name(): with pytest.raises(SystemExit): - cli(["launch", "--port", 0, "--no-open-browser", "--verbose"]) + cli([]) diff --git a/skore/tests/unit/cli/test_quickstart.py b/skore/tests/unit/cli/test_quickstart.py deleted file mode 100644 index 40c65f0cb..000000000 --- a/skore/tests/unit/cli/test_quickstart.py +++ /dev/null @@ -1,59 +0,0 @@ -from skore.cli.cli import cli - - -def test_quickstart(monkeypatch, tmp_path): - """`quickstart` passes its arguments down to `create` and `launch`.""" - - create_project_name = None - create_overwrite = None - create_verbose = None - - def fake_create(project_name, overwrite, verbose): - nonlocal create_project_name - nonlocal create_overwrite - nonlocal create_verbose - - create_project_name = project_name - create_overwrite = overwrite - create_verbose = verbose - - monkeypatch.setattr("skore.cli.quickstart_command._create", fake_create) - - launch_project_name = None - launch_port = None - launch_open_browser = None - launch_verbose = None - - def fake_launch(project_name, port, open_browser, verbose): - nonlocal launch_project_name - nonlocal launch_port - nonlocal launch_open_browser - nonlocal launch_verbose - - launch_project_name = project_name - launch_port = port - launch_open_browser = open_browser - launch_verbose = verbose - - monkeypatch.setattr("skore.cli.quickstart_command.__launch", fake_launch) - - cli( - [ - "quickstart", - str(tmp_path / "my_project.skore"), - "--verbose", - "--overwrite", - "--port", - "888", - "--no-open-browser", - ] - ) - - assert create_project_name == str(tmp_path / "my_project.skore") - assert create_overwrite is True - assert create_verbose is True - - assert launch_project_name == tmp_path / "my_project.skore" - assert launch_port == 888 - assert launch_open_browser is False - assert launch_verbose is True diff --git a/skore/tests/unit/project/test_display_as.py b/skore/tests/unit/project/test_display_as.py index 7f38b3b1e..6ee21859b 100644 --- a/skore/tests/unit/project/test_display_as.py +++ b/skore/tests/unit/project/test_display_as.py @@ -10,7 +10,7 @@ def monkeypatch_datetime(monkeypatch, MockDatetime): def test_str_without_display_as(in_memory_project, mock_nowstr): in_memory_project.put("key", "") - item = in_memory_project.item_repository.get_item("key") + item = in_memory_project._item_repository.get_item("key") assert isinstance(item, MediaItem) assert item.media == "" @@ -21,7 +21,7 @@ def test_str_without_display_as(in_memory_project, mock_nowstr): def test_str_with_display_as(in_memory_project, mock_nowstr, media_type): in_memory_project.put("key", "", display_as=media_type.name) - item = in_memory_project.item_repository.get_item("key") + item = in_memory_project._item_repository.get_item("key") assert isinstance(item, MediaItem) assert item.media == "" diff --git a/skore/tests/unit/project/test_load.py b/skore/tests/unit/project/test_load.py deleted file mode 100644 index 06dd4b602..000000000 --- a/skore/tests/unit/project/test_load.py +++ /dev/null @@ -1,32 +0,0 @@ -import os - -import pytest -from skore.project import Project -from skore.project.load import _load - - -@pytest.fixture -def tmp_project_path(tmp_path): - """Create a project at `tmp_path` and return its absolute path.""" - # Project path must end with ".skore" - project_path = tmp_path.parent / (tmp_path.name + ".skore") - os.mkdir(project_path) - os.mkdir(project_path / "items") - os.mkdir(project_path / "views") - return project_path - - -def test_load_no_project(): - with pytest.raises(FileNotFoundError): - _load("/empty") - - -def test_load_absolute_path(tmp_project_path): - p = _load(tmp_project_path) - assert isinstance(p, Project) - - -def test_load_relative_path(tmp_project_path): - os.chdir(tmp_project_path.parent) - p = _load(tmp_project_path.name) - assert isinstance(p, Project) diff --git a/skore/tests/unit/project/test_open.py b/skore/tests/unit/project/test_open.py deleted file mode 100644 index 9f5bc0d71..000000000 --- a/skore/tests/unit/project/test_open.py +++ /dev/null @@ -1,93 +0,0 @@ -import os -from contextlib import contextmanager - -import pytest -from skore.project import Project, open - - -@pytest.fixture -def tmp_project_path(tmp_path): - """Create a project at `tmp_path` and return its absolute path.""" - # Project path must end with ".skore" - project_path = tmp_path.parent / (tmp_path.name + ".skore") - os.mkdir(project_path) - os.mkdir(project_path / "items") - os.mkdir(project_path / "views") - return project_path - - -@contextmanager -def project_changed(project_path, modified=True): - """Assert that the project at `project_path` was changed. - - If `modified` is False, instead assert that it was *not* changed. - """ - (project_path / "my_test_file").write_text("hello") - yield - if modified: - assert not (project_path / "my_test_file").exists() - else: - assert (project_path / "my_test_file").exists() - - -def test_open_relative_path(tmp_project_path): - """If passed a relative path, `open` operates in the current working directory.""" - os.chdir(tmp_project_path.parent) - p = open(tmp_project_path.name, create=False) - assert isinstance(p, Project) - - -def test_open_default(tmp_project_path): - """If a project already exists, `open` loads it.""" - with project_changed(tmp_project_path, modified=False): - p = open(tmp_project_path) - assert isinstance(p, Project) - - -def test_open_default_no_project(tmp_path): - """If no project exists, `open` creates it.""" - with project_changed(tmp_path, modified=False): - p = open(tmp_path) - assert isinstance(p, Project) - - -def test_open_project_exists_create_true_overwrite_true(tmp_project_path): - """If the project exists, and `create` and `overwrite` are set to `True`, - `open` overwrites it with a new one.""" - with project_changed(tmp_project_path): - open(tmp_project_path, create=True, overwrite=True) - - -def test_open_project_exists_create_true_overwrite_false(tmp_project_path): - with project_changed(tmp_project_path, modified=False): - open(tmp_project_path, create=True, overwrite=False) - - -def test_open_project_exists_create_false_overwrite_true(tmp_project_path): - p = open(tmp_project_path, create=False, overwrite=True) - assert isinstance(p, Project) - - -def test_open_project_exists_create_false_overwrite_false(tmp_project_path): - p = open(tmp_project_path, create=False, overwrite=False) - assert isinstance(p, Project) - - -def test_open_no_project_create_true_overwrite_true(tmp_path): - p = open(tmp_path, create=True, overwrite=True) - assert isinstance(p, Project) - - -def test_open_no_project_create_true_overwrite_false(tmp_path): - p = open(tmp_path, create=True, overwrite=False) - assert isinstance(p, Project) - - -def test_open_no_project_create_false_overwrite_true(tmp_path): - with pytest.raises(FileNotFoundError): - open(tmp_path, create=False, overwrite=True) - - -def test_open_no_project_create_false_overwrite_false(tmp_path): - with pytest.raises(FileNotFoundError): - open(tmp_path, create=False, overwrite=False) diff --git a/skore/tests/unit/project/test_project.py b/skore/tests/unit/project/test_project.py index eacd24d95..829b460ed 100644 --- a/skore/tests/unit/project/test_project.py +++ b/skore/tests/unit/project/test_project.py @@ -11,11 +11,7 @@ from matplotlib.testing.compare import compare_images from PIL import Image from sklearn.ensemble import RandomForestClassifier -from skore.exceptions import ( - InvalidProjectNameError, - ProjectCreationError, -) -from skore.project.create import _create, _validate_project_name +from skore import Project @pytest.fixture(autouse=True) @@ -23,6 +19,39 @@ def monkeypatch_datetime(monkeypatch, MockDatetime): monkeypatch.setattr("skore.persistence.item.item.datetime", MockDatetime) +def test_init(tmp_path): + dirpath = tmp_path / "my-project.skore" + + # Ensure missing project can be created + Project(dirpath) + + assert dirpath.exists() + assert (dirpath / "items").exists() + assert (dirpath / "views").exists() + + # Ensure existing project raises an error with `if_exists="raise"` + with pytest.raises(FileExistsError): + Project(dirpath, if_exists="raise") + + # Ensure existing project can be loaded with `if_exists="load"` + Project(dirpath, if_exists="load") + + +def test_clear(tmp_path): + dirpath = tmp_path / "my-project.skore" + project = Project(dirpath) + + project.put("", "") + + assert project.keys() == [""] + + project.clear() + + assert project.keys() == [] + assert project._item_repository.keys() == [] + assert project._view_repository.keys() == ["default"] + + def test_put_string_item(in_memory_project): in_memory_project.put("string_item", "Hello, World!") assert in_memory_project.get("string_item") == "Hello, World!" @@ -264,58 +293,3 @@ def test_put_wrong_key_and_value_raise(in_memory_project): """When `on_error` is "raise", raise the first error that occurs.""" with pytest.raises(TypeError): in_memory_project.put(0, (lambda: "unsupported object")) - - -test_cases = [ - ( - "a" * 250, - (False, InvalidProjectNameError()), - ), - ( - "%", - (False, InvalidProjectNameError()), - ), - ( - "hello world", - (False, InvalidProjectNameError()), - ), -] - - -@pytest.mark.parametrize("project_name,expected", test_cases) -def test_validate_project_name(project_name, expected): - result, exception = _validate_project_name(project_name) - expected_result, expected_exception = expected - assert result == expected_result - assert type(exception) is type(expected_exception) - - -@pytest.mark.parametrize("project_name", ["hello", "hello.skore"]) -def test_create_project(project_name, tmp_path): - _create(tmp_path / project_name) - assert (tmp_path / "hello.skore").exists() - - -# TODO: If using fixtures in test cases is possible, join this with -# `test_create_project` -def test_create_project_absolute_path(tmp_path): - _create(tmp_path / "hello") - assert (tmp_path / "hello.skore").exists() - - -def test_create_project_fails_if_file_exists(tmp_path): - _create(tmp_path / "hello") - assert (tmp_path / "hello.skore").exists() - with pytest.raises(FileExistsError): - _create(tmp_path / "hello") - - -def test_create_project_fails_if_permission_denied(tmp_path): - with pytest.raises(ProjectCreationError): - _create("/") - - -@pytest.mark.parametrize("project_name", ["hello.txt", "%%%", "COM1"]) -def test_create_project_fails_if_invalid_name(project_name, tmp_path): - with pytest.raises(ProjectCreationError): - _create(tmp_path / project_name) diff --git a/sphinx/api.rst b/sphinx/api.rst index 6ab3de36a..d30f1bcd7 100644 --- a/sphinx/api.rst +++ b/sphinx/api.rst @@ -19,7 +19,6 @@ These functions and classes are meant for managing a Project. :template: base.rst :caption: Managing a project - open Project Project.put Project.get