From bc39557fdbdd4ac286c0452a3fe95324910abb73 Mon Sep 17 00:00:00 2001 From: Guillaume Lemaitre Date: Mon, 6 Jan 2025 09:18:28 +0100 Subject: [PATCH 01/14] feat: Use friendly verbose and colorish (#962) closes #959 closes #812 This PR proposes to: - use a default `logger` that does not show any information by default - introduce a `--verbose` option for the CLI or `verbose` parameter for the functions - a decorator that register a rich handler depending of the `verbose` parameter - introduce a `rich` console with cyan/orange colors (as the default `skore` theme) for showing the pertinent information. --- skore/src/skore/__init__.py | 35 ++---- skore/src/skore/cli/__init__.py | 9 +- skore/src/skore/cli/cli.py | 18 +++ skore/src/skore/cli/launch_dashboard.py | 47 ++++--- skore/src/skore/cli/quickstart_command.py | 33 +++-- skore/src/skore/project/create.py | 144 ++++++++++++---------- skore/src/skore/project/project.py | 2 + skore/src/skore/utils/_logger.py | 37 ++++++ skore/tests/unit/cli/test_cli.py | 10 +- skore/tests/unit/cli/test_quickstart.py | 13 +- skore/tests/unit/utils/test_logger.py | 43 +++++++ 11 files changed, 261 insertions(+), 130 deletions(-) create mode 100644 skore/src/skore/utils/_logger.py create mode 100644 skore/tests/unit/utils/test_logger.py diff --git a/skore/src/skore/__init__.py b/skore/src/skore/__init__.py index afb3337f2..5fbe81c28 100644 --- a/skore/src/skore/__init__.py +++ b/skore/src/skore/__init__.py @@ -2,7 +2,8 @@ import logging -import rich.logging +from rich.console import Console +from rich.theme import Theme from skore.project import Project, create, load from skore.sklearn import CrossValidationReporter, train_test_split @@ -17,26 +18,16 @@ "train_test_split", ] - -class Handler(rich.logging.RichHandler): - """A logging handler that renders output with Rich.""" - - def get_level_text(self, record: rich.logging.LogRecord) -> rich.logging.Text: - """Get the logger name and levelname from the record.""" - levelname = record.levelname - name = record.name - txt = f"" - - return rich.logging.Text.styled( - txt.ljust(8), - f"logging.level.{levelname.lower()}", - ) - - -formatter = logging.Formatter("%(message)s") -handler = Handler(markup=True) -handler.setFormatter(formatter) - logger = logging.getLogger(__name__) -logger.addHandler(handler) +logger.addHandler(logging.NullHandler()) # Default to no output logger.setLevel(logging.INFO) + +skore_console_theme = Theme( + { + "repr.str": "cyan", + "rule.line": "orange1", + "repr.url": "orange1", + } +) + +console = Console(theme=skore_console_theme, width=79) diff --git a/skore/src/skore/cli/__init__.py b/skore/src/skore/cli/__init__.py index a3a5aae7b..31a0b8ede 100644 --- a/skore/src/skore/cli/__init__.py +++ b/skore/src/skore/cli/__init__.py @@ -2,12 +2,7 @@ import logging -formatter = logging.Formatter("%(message)s") - -console_handler = logging.StreamHandler() -console_handler.setLevel(logging.INFO) -console_handler.setFormatter(formatter) - logger = logging.getLogger(__name__) -logger.addHandler(console_handler) +logger.addHandler(logging.NullHandler()) # Default to no output +logger.setLevel(logging.INFO) logger.propagate = False diff --git a/skore/src/skore/cli/cli.py b/skore/src/skore/cli/cli.py index 33aba096d..e63440b69 100644 --- a/skore/src/skore/cli/cli.py +++ b/skore/src/skore/cli/cli.py @@ -39,6 +39,11 @@ 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( @@ -61,6 +66,11 @@ def cli(args: list[str]): action="store_true", help="overwrite an existing project with the same name", ) + parser_create.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' @@ -100,6 +110,11 @@ def cli(args: list[str]): ), default=True, ) + parser_quickstart.add_argument( + "--verbose", + action="store_true", + help="increase logging verbosity", + ) parsed_args: argparse.Namespace = parser.parse_args(args) @@ -108,12 +123,14 @@ def cli(args: list[str]): 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, working_dir=parsed_args.working_dir, overwrite=parsed_args.overwrite, + verbose=parsed_args.verbose, ) elif parsed_args.subcommand == "quickstart": __quickstart( @@ -122,6 +139,7 @@ def cli(args: list[str]): overwrite=parsed_args.overwrite, port=parsed_args.port, open_browser=parsed_args.open_browser, + verbose=parsed_args.verbose, ) else: parser.print_help() diff --git a/skore/src/skore/cli/launch_dashboard.py b/skore/src/skore/cli/launch_dashboard.py index ce5aa8c36..a18ea03b9 100644 --- a/skore/src/skore/cli/launch_dashboard.py +++ b/skore/src/skore/cli/launch_dashboard.py @@ -11,9 +11,12 @@ from skore.cli import logger from skore.project import load from skore.ui.app import create_app +from skore.utils._logger import logger_context -def __launch(project_name: Union[str, Path], port: int, open_browser: bool): +def __launch( + project_name: Union[str, Path], port: int, open_browser: bool, verbose: bool = False +): """Launch the UI to visualize a project. Parameters @@ -24,22 +27,28 @@ def __launch(project_name: Union[str, Path], port: int, open_browser: bool): 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 display info logs to the user. """ - project = load(project_name) - - @asynccontextmanager - async def lifespan(app: FastAPI): - if open_browser: - webbrowser.open(f"http://localhost:{port}") - yield - - app = create_app(project=project, lifespan=lifespan) - - try: - # TODO: check port is free - logger.info( - f"Running skore UI from '{project_name}' at URL http://localhost:{port}" - ) - uvicorn.run(app, port=port, log_level="error") - except KeyboardInterrupt: - logger.info("Closing skore UI") + from skore import console # avoid circular import + + with logger_context(logger, verbose): + project = load(project_name) + + @asynccontextmanager + async def lifespan(app: FastAPI): + if open_browser: + webbrowser.open(f"http://localhost:{port}") + yield + + app = create_app(project=project, lifespan=lifespan) + + try: + # TODO: check port is free + console.rule("[bold cyan]skore-UI[/bold cyan]") + console.print( + f"Running skore UI from '{project_name}' at URL http://localhost:{port}" + ) + uvicorn.run(app, port=port, log_level="error") + except KeyboardInterrupt: + console.print("Closing skore UI") diff --git a/skore/src/skore/cli/quickstart_command.py b/skore/src/skore/cli/quickstart_command.py index 9a75ea43b..4d46619f0 100644 --- a/skore/src/skore/cli/quickstart_command.py +++ b/skore/src/skore/cli/quickstart_command.py @@ -7,6 +7,7 @@ from skore.cli.launch_dashboard import __launch from skore.exceptions import ProjectAlreadyExistsError from skore.project import create +from skore.utils._logger import logger_context def __quickstart( @@ -15,6 +16,7 @@ def __quickstart( overwrite: bool, port: int, open_browser: bool, + verbose: bool = False, ): """Quickstart a Skore project. @@ -36,16 +38,25 @@ def __quickstart( 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. """ - try: - create(project_name=project_name, working_dir=working_dir, overwrite=overwrite) - except ProjectAlreadyExistsError: - logger.info( - f"Project file '{project_name}' already exists. Skipping creation step." - ) + with logger_context(logger, verbose): + try: + create( + project_name=project_name, + working_dir=working_dir, + overwrite=overwrite, + verbose=verbose, + ) + except ProjectAlreadyExistsError: + logger.info( + f"Project file '{project_name}' already exists. Skipping creation step." + ) - __launch( - project_name=project_name, - port=port, - open_browser=open_browser, - ) + __launch( + project_name=project_name, + port=port, + open_browser=open_browser, + verbose=verbose, + ) diff --git a/skore/src/skore/project/create.py b/skore/src/skore/project/create.py index e9ffc786e..bee8449d5 100644 --- a/skore/src/skore/project/create.py +++ b/skore/src/skore/project/create.py @@ -13,6 +13,7 @@ ) from skore.project.load import load from skore.project.project import Project, logger +from skore.utils._logger import logger_context from skore.view.view import View @@ -61,6 +62,7 @@ def create( project_name: Union[str, Path], working_dir: Optional[Path] = None, overwrite: bool = False, + verbose: bool = False, ) -> Project: """Create a project file named according to ``project_name``. @@ -76,74 +78,84 @@ def create( 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 """ - 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 - - # NOTE: `working_dir` has no effect if `checked_project_name` is absolute - if working_dir is None: - working_dir = Path.cwd() - project_directory = working_dir / ( - project_path.with_name(checked_project_name + ".skore") - ) - - if project_directory.exists(): - if not overwrite: - raise ProjectAlreadyExistsError( - 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.put_view("default", View(layout=[])) - - logger.info(f"Project file '{project_directory}' was successfully created.") - return p + 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 + + # NOTE: `working_dir` has no effect if `checked_project_name` is absolute + if working_dir is None: + working_dir = Path.cwd() + project_directory = working_dir / ( + project_path.with_name(checked_project_name + ".skore") + ) + + if project_directory.exists(): + if not overwrite: + raise ProjectAlreadyExistsError( + 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.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/project.py b/skore/src/skore/project/project.py index 19918d964..289b04d10 100644 --- a/skore/src/skore/project/project.py +++ b/skore/src/skore/project/project.py @@ -21,6 +21,8 @@ from skore.view.view_repository import ViewRepository logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) # Default to no output +logger.setLevel(logging.INFO) MISSING = object() diff --git a/skore/src/skore/utils/_logger.py b/skore/src/skore/utils/_logger.py new file mode 100644 index 000000000..4b601d262 --- /dev/null +++ b/skore/src/skore/utils/_logger.py @@ -0,0 +1,37 @@ +"""Module to handle the verbosity of a given logger.""" + +import logging +from contextlib import contextmanager + +from rich.logging import LogRecord, RichHandler, Text + + +class Handler(RichHandler): + """A logging handler that renders output with Rich.""" + + def get_level_text(self, record: LogRecord) -> Text: + """Get the logger name and levelname from the record.""" + levelname = record.levelname + name = record.name + txt = f"" + + return Text.styled( + txt.ljust(8), + f"logging.level.{levelname.lower()}", + ) + + +@contextmanager +def logger_context(logger, verbose=False): + """Context manager for temporarily adding a Rich handler to a logger.""" + handler = None + try: + if verbose: + formatter = logging.Formatter("%(message)s") + handler = Handler(markup=True) + handler.setFormatter(formatter) + logger.addHandler(handler) + yield + finally: + if verbose and handler: + logger.removeHandler(handler) diff --git a/skore/tests/unit/cli/test_cli.py b/skore/tests/unit/cli/test_cli.py index 8d9b56dcb..de72e4f01 100644 --- a/skore/tests/unit/cli/test_cli.py +++ b/skore/tests/unit/cli/test_cli.py @@ -8,24 +8,28 @@ def test_cli_launch(monkeypatch): launch_project_name = None launch_port = None launch_open_browser = None + launch_verbose = None - def fake_launch(project_name, port, open_browser): + 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.cli.__launch", fake_launch) - cli(["launch", "project.skore", "--port", "0", "--no-open-browser"]) + cli(["launch", "project.skore", "--port", "0", "--no-open-browser", "--verbose"]) assert launch_project_name == "project.skore" assert launch_port == 0 assert not launch_open_browser + assert launch_verbose def test_cli_launch_no_project_name(): with pytest.raises(SystemExit): - cli(["launch", "--port", 0, "--no-open-browser"]) + cli(["launch", "--port", 0, "--no-open-browser", "--verbose"]) diff --git a/skore/tests/unit/cli/test_quickstart.py b/skore/tests/unit/cli/test_quickstart.py index d802d9986..500974194 100644 --- a/skore/tests/unit/cli/test_quickstart.py +++ b/skore/tests/unit/cli/test_quickstart.py @@ -9,30 +9,36 @@ def test_quickstart(monkeypatch): create_project_name = None create_working_dir = None create_overwrite = None + create_verbose = None - def fake_create(project_name, working_dir, overwrite): + def fake_create(project_name, working_dir, overwrite, verbose): nonlocal create_project_name nonlocal create_working_dir nonlocal create_overwrite + nonlocal create_verbose create_project_name = project_name create_working_dir = working_dir 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): + 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) @@ -40,6 +46,7 @@ def fake_launch(project_name, port, open_browser): [ "quickstart", "my_project.skore", + "--verbose", "--overwrite", "--working-dir", "hello", @@ -52,7 +59,9 @@ def fake_launch(project_name, port, open_browser): assert create_project_name == "my_project.skore" assert create_working_dir == Path("hello") assert create_overwrite is True + assert create_verbose is True assert launch_project_name == "my_project.skore" assert launch_port == 888 assert launch_open_browser is False + assert launch_verbose is True diff --git a/skore/tests/unit/utils/test_logger.py b/skore/tests/unit/utils/test_logger.py new file mode 100644 index 000000000..a05c31775 --- /dev/null +++ b/skore/tests/unit/utils/test_logger.py @@ -0,0 +1,43 @@ +import logging + +import pytest +from skore.utils._logger import Handler, logger_context + + +def test_logger_context_verbose(): + """Test that logger_context properly adds and removes handler when verbose=True.""" + logger = logging.getLogger("test_logger") + initial_handlers = list(logger.handlers) + + with logger_context(logger, verbose=True): + current_handlers = list(logger.handlers) + assert len(current_handlers) == len(initial_handlers) + 1 + added_handler = current_handlers[-1] + assert isinstance(added_handler, Handler) + assert added_handler.formatter._fmt == "%(message)s" + assert added_handler.markup is True + + final_handlers = list(logger.handlers) + assert final_handlers == initial_handlers + + +def test_logger_context_non_verbose(): + """Test that logger_context doesn't modify handlers when verbose=False.""" + logger = logging.getLogger("test_logger") + initial_handlers = list(logger.handlers) + + with logger_context(logger, verbose=False): + assert list(logger.handlers) == initial_handlers + + assert list(logger.handlers) == initial_handlers + + +def test_logger_context_exception(): + """Test that logger_context removes handler even if an exception occurs.""" + logger = logging.getLogger("test_logger") + initial_handlers = list(logger.handlers) + + with pytest.raises(ValueError), logger_context(logger, verbose=True): + raise ValueError("Test exception") + + assert list(logger.handlers) == initial_handlers From 8d032ab4198f3767fb0654e480c99466c5b49933 Mon Sep 17 00:00:00 2001 From: "Thomas S." Date: Mon, 6 Jan 2025 15:07:02 +0100 Subject: [PATCH 02/14] docs: Fix `CONTRIBUTING.md` to create project (#1049) Closes #1026 . --- CONTRIBUTING.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 779d96b9b..86ef2a244 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -36,6 +36,7 @@ You'll need ``python >=3.9, <3.13`` to build the backend and ``Node>=20`` to bui .. code-block:: bash make install-skore + skore create make build-skore-ui make serve-skore-ui From a4695e0f30bc95cb4e58cebe1822be2ce843347c Mon Sep 17 00:00:00 2001 From: "Thomas S." Date: Mon, 6 Jan 2025 15:26:15 +0100 Subject: [PATCH 03/14] fix: Fix quickstart instruction in CLI (#1048) Closes #995 --- skore/src/skore/cli/launch_dashboard.py | 5 ++++- skore/src/skore/cli/quickstart_command.py | 12 +++++++++--- skore/tests/unit/cli/test_quickstart.py | 6 +++--- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/skore/src/skore/cli/launch_dashboard.py b/skore/src/skore/cli/launch_dashboard.py index a18ea03b9..239e20e5f 100644 --- a/skore/src/skore/cli/launch_dashboard.py +++ b/skore/src/skore/cli/launch_dashboard.py @@ -15,7 +15,10 @@ def __launch( - project_name: Union[str, Path], port: int, open_browser: bool, verbose: bool = False + project_name: Union[str, Path], + port: int, + open_browser: bool, + verbose: bool = False, ): """Launch the UI to visualize a project. diff --git a/skore/src/skore/cli/quickstart_command.py b/skore/src/skore/cli/quickstart_command.py index 4d46619f0..33c849f52 100644 --- a/skore/src/skore/cli/quickstart_command.py +++ b/skore/src/skore/cli/quickstart_command.py @@ -1,7 +1,7 @@ """Implement the "quickstart" command.""" from pathlib import Path -from typing import Optional, Union +from typing import Union from skore.cli import logger from skore.cli.launch_dashboard import __launch @@ -12,7 +12,7 @@ def __quickstart( project_name: Union[str, Path], - working_dir: Optional[Path], + working_dir: Union[Path, None], overwrite: bool, port: int, open_browser: bool, @@ -54,8 +54,14 @@ def __quickstart( f"Project file '{project_name}' already exists. Skipping creation step." ) + path = ( + Path(project_name) + if working_dir is None + else Path(working_dir, project_name) + ) + __launch( - project_name=project_name, + project_name=path, port=port, open_browser=open_browser, verbose=verbose, diff --git a/skore/tests/unit/cli/test_quickstart.py b/skore/tests/unit/cli/test_quickstart.py index 500974194..81114bde8 100644 --- a/skore/tests/unit/cli/test_quickstart.py +++ b/skore/tests/unit/cli/test_quickstart.py @@ -49,7 +49,7 @@ def fake_launch(project_name, port, open_browser, verbose): "--verbose", "--overwrite", "--working-dir", - "hello", + "/tmp", "--port", "888", "--no-open-browser", @@ -57,11 +57,11 @@ def fake_launch(project_name, port, open_browser, verbose): ) assert create_project_name == "my_project.skore" - assert create_working_dir == Path("hello") + assert create_working_dir == Path("/tmp") assert create_overwrite is True assert create_verbose is True - assert launch_project_name == "my_project.skore" + assert launch_project_name == Path("/tmp/my_project.skore") assert launch_port == 888 assert launch_open_browser is False assert launch_verbose is True From 2f7682465be48b778df230713507d4f9f5302665 Mon Sep 17 00:00:00 2001 From: Auguste Baum <52001167+augustebaum@users.noreply.github.com> Date: Mon, 6 Jan 2025 17:04:24 +0100 Subject: [PATCH 04/14] docs: Add ADR on `neg_*` metrics (#1051) Closes #1020 --------- Co-authored-by: Thomas S. Co-authored-by: Sylvain Combettes <48064216+sylvaincom@users.noreply.github.com> --- ...-never-present-neg-metrics-from-sklearn.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docs/design/0002-never-present-neg-metrics-from-sklearn.md diff --git a/docs/design/0002-never-present-neg-metrics-from-sklearn.md b/docs/design/0002-never-present-neg-metrics-from-sklearn.md new file mode 100644 index 000000000..743e41dfe --- /dev/null +++ b/docs/design/0002-never-present-neg-metrics-from-sklearn.md @@ -0,0 +1,27 @@ +--- +status: accepted +date: 2025-01-06 +decision-makers: ["@augustebaum", "@sylvaincom", "@glemaitre"] +consulted: ["@ogrisel"] +--- + +# Never show `neg_*` metrics from sklearn + +## Context and Problem Statement + +We show various metrics to users, many directly using sklearn. +In sklearn, many metrics are multiplied by -1 and prefixed with `neg_`, with the purpose of making all metrics "higher-is-better". This way, optimization tools in sklearn such as `GridSearchCV` do not need to figure out which way the metric should be optimized. +This is specific to sklearn, and there is no reason to port this design over to skore. + +## Decision Drivers + +* Our data-science-literate collaborators (@ogrisel, @glemaitre, @sylvaincom) consider the `neg_` trick should remain a solution to a sklearn-specific problem, and not be displayed in plots for the skore user. + +## Decision Outcome + +Chosen option: Never show `neg_*` metrics from sklearn in skore, only use the positive counterparts. This makes reports clearer. + +### Consequences + +* We show the most relevant information to the user. +* We might have to take on the responsibility of maintaining the "metric is higher-is-better" pre-condition ourselves. From 1397c791692d2427a116c85c6253bc92e39359c9 Mon Sep 17 00:00:00 2001 From: Auguste Baum <52001167+augustebaum@users.noreply.github.com> Date: Tue, 7 Jan 2025 17:25:43 +0100 Subject: [PATCH 05/14] feat: Add `open` command (#992) Closes #917 This PR introduces a new `skore.open` command which can either `load` or `create` a project. Accordingly, the CLI has been somewhat revamped and the test suite refined. 1. The `create` CLI command test suite was changed from relying on `subprocess` to calling `cli` directly, which makes testing easier and also makes the tests much faster (test time was cut down by 50% on my machine). 2. The `working_directory` options to `create` and `quickstart` were removed, as they were superfluous and only used for testing. It is recommended to use absolute paths instead. The commits are fairly independent, so I might switch to a stacked PR workflow later. --- examples/getting_started/plot_quick_start.py | 2 +- .../plot_skore_product_tour.py | 4 +- .../getting_started/plot_tracking_items.py | 2 +- .../plot_working_with_projects.py | 2 +- .../model_evaluation/plot_cross_validate.py | 2 +- .../model_evaluation/plot_train_test_split.py | 2 +- skore/src/skore/__init__.py | 5 +- skore/src/skore/cli/cli.py | 25 +---- skore/src/skore/cli/launch_dashboard.py | 4 +- skore/src/skore/cli/quickstart_command.py | 22 +---- skore/src/skore/exceptions.py | 4 - skore/src/skore/project/__init__.py | 6 +- skore/src/skore/project/create.py | 25 ++--- skore/src/skore/project/load.py | 21 +++-- skore/src/skore/project/open.py | 53 +++++++++++ skore/src/skore/ui/app.py | 4 +- skore/tests/integration/cli/test_create.py | 56 +++++------ .../tests/integration/cli/test_quickstart.py | 25 +++++ skore/tests/unit/cli/test_quickstart.py | 20 ++-- skore/tests/unit/project/test_load.py | 32 +++++++ skore/tests/unit/project/test_open.py | 93 +++++++++++++++++++ .../tests/unit/{ => project}/test_project.py | 43 ++------- sphinx/api.rst | 3 +- 23 files changed, 279 insertions(+), 176 deletions(-) create mode 100644 skore/src/skore/project/open.py create mode 100644 skore/tests/integration/cli/test_quickstart.py create mode 100644 skore/tests/unit/project/test_load.py create mode 100644 skore/tests/unit/project/test_open.py rename skore/tests/unit/{ => project}/test_project.py (90%) diff --git a/examples/getting_started/plot_quick_start.py b/examples/getting_started/plot_quick_start.py index 871de9453..421ca0328 100644 --- a/examples/getting_started/plot_quick_start.py +++ b/examples/getting_started/plot_quick_start.py @@ -12,7 +12,7 @@ # %% import skore -my_project = skore.create("quick_start", overwrite=True) +my_project = skore.open("quick_start", overwrite=True) # %% # This will create a skore project directory named ``quick_start.skore`` in your diff --git a/examples/getting_started/plot_skore_product_tour.py b/examples/getting_started/plot_skore_product_tour.py index 1002db781..a90dfa4f9 100644 --- a/examples/getting_started/plot_skore_product_tour.py +++ b/examples/getting_started/plot_skore_product_tour.py @@ -44,7 +44,7 @@ # # import skore # -# my_project = skore.create("my_project") +# my_project = skore.open("my_project") # # This would create a skore project directory named ``my_project.skore`` in our # current directory. @@ -66,7 +66,7 @@ # %% import skore -my_project = skore.create("my_project", working_dir=temp_dir_path) +my_project = skore.open(temp_dir_path / "my_project") # %% # Then, *from our shell* (in the same directory), we can start the UI locally: diff --git a/examples/getting_started/plot_tracking_items.py b/examples/getting_started/plot_tracking_items.py index c03c6590b..11bfe30aa 100644 --- a/examples/getting_started/plot_tracking_items.py +++ b/examples/getting_started/plot_tracking_items.py @@ -30,7 +30,7 @@ # %% import skore -my_project = skore.create("my_project", working_dir=temp_dir_path) +my_project = skore.open(temp_dir_path / "my_project") # %% # Tracking an integer diff --git a/examples/getting_started/plot_working_with_projects.py b/examples/getting_started/plot_working_with_projects.py index 880b3ac7e..0b16b2074 100644 --- a/examples/getting_started/plot_working_with_projects.py +++ b/examples/getting_started/plot_working_with_projects.py @@ -30,7 +30,7 @@ # %% import skore -my_project = skore.create("my_project", working_dir=temp_dir_path) +my_project = skore.open(temp_dir_path / "my_project") # %% # Storing integers diff --git a/examples/model_evaluation/plot_cross_validate.py b/examples/model_evaluation/plot_cross_validate.py index d298538ef..9422e37b0 100644 --- a/examples/model_evaluation/plot_cross_validate.py +++ b/examples/model_evaluation/plot_cross_validate.py @@ -30,7 +30,7 @@ # %% import skore -my_project = skore.create("my_project", working_dir=temp_dir_path) +my_project = skore.open(temp_dir_path / "my_project") # %% # Cross-validation in scikit-learn diff --git a/examples/model_evaluation/plot_train_test_split.py b/examples/model_evaluation/plot_train_test_split.py index 8ade0f3ac..7a973e036 100644 --- a/examples/model_evaluation/plot_train_test_split.py +++ b/examples/model_evaluation/plot_train_test_split.py @@ -30,7 +30,7 @@ # %% import skore -my_project = skore.create("my_project", working_dir=temp_dir_path) +my_project = skore.open(temp_dir_path / "my_project") # %% # Train-test split in scikit-learn diff --git a/skore/src/skore/__init__.py b/skore/src/skore/__init__.py index 5fbe81c28..1f33543cf 100644 --- a/skore/src/skore/__init__.py +++ b/skore/src/skore/__init__.py @@ -5,14 +5,13 @@ from rich.console import Console from rich.theme import Theme -from skore.project import Project, create, load +from skore.project import Project, open from skore.sklearn import CrossValidationReporter, train_test_split from skore.utils._show_versions import show_versions __all__ = [ - "create", "CrossValidationReporter", - "load", + "open", "Project", "show_versions", "train_test_split", diff --git a/skore/src/skore/cli/cli.py b/skore/src/skore/cli/cli.py index e63440b69..f66eb269d 100644 --- a/skore/src/skore/cli/cli.py +++ b/skore/src/skore/cli/cli.py @@ -1,12 +1,11 @@ """CLI for Skore.""" import argparse -import pathlib from importlib.metadata import version from skore.cli.launch_dashboard import __launch from skore.cli.quickstart_command import __quickstart -from skore.project import create +from skore.project.create import _create def cli(args: list[str]): @@ -52,15 +51,6 @@ def cli(args: list[str]): help="the name or path of the project to create (default: %(default)s)", default="project", ) - parser_create.add_argument( - "--working-dir", - type=pathlib.Path, - help=( - "the directory relative to which the project name will be interpreted; " - "default is the current working directory (mostly used for testing)" - ), - default=None, - ) parser_create.add_argument( "--overwrite", action="store_true", @@ -81,15 +71,6 @@ def cli(args: list[str]): help="the name or path of the project to create (default: %(default)s)", default="project", ) - parser_quickstart.add_argument( - "--working-dir", - type=pathlib.Path, - help=( - "the directory relative to which the project name will be interpreted; " - "default is the current working directory (mostly used for testing)" - ), - default=None, - ) parser_quickstart.add_argument( "--overwrite", action="store_true", @@ -126,16 +107,14 @@ def cli(args: list[str]): verbose=parsed_args.verbose, ) elif parsed_args.subcommand == "create": - create( + _create( project_name=parsed_args.project_name, - working_dir=parsed_args.working_dir, overwrite=parsed_args.overwrite, verbose=parsed_args.verbose, ) elif parsed_args.subcommand == "quickstart": __quickstart( project_name=parsed_args.project_name, - working_dir=parsed_args.working_dir, overwrite=parsed_args.overwrite, port=parsed_args.port, open_browser=parsed_args.open_browser, diff --git a/skore/src/skore/cli/launch_dashboard.py b/skore/src/skore/cli/launch_dashboard.py index 239e20e5f..368838c81 100644 --- a/skore/src/skore/cli/launch_dashboard.py +++ b/skore/src/skore/cli/launch_dashboard.py @@ -9,7 +9,7 @@ from fastapi import FastAPI from skore.cli import logger -from skore.project import load +from skore.project import open from skore.ui.app import create_app from skore.utils._logger import logger_context @@ -36,7 +36,7 @@ def __launch( from skore import console # avoid circular import with logger_context(logger, verbose): - project = load(project_name) + project = open(project_name) @asynccontextmanager async def lifespan(app: FastAPI): diff --git a/skore/src/skore/cli/quickstart_command.py b/skore/src/skore/cli/quickstart_command.py index 33c849f52..e2aa0c394 100644 --- a/skore/src/skore/cli/quickstart_command.py +++ b/skore/src/skore/cli/quickstart_command.py @@ -5,14 +5,12 @@ from skore.cli import logger from skore.cli.launch_dashboard import __launch -from skore.exceptions import ProjectAlreadyExistsError -from skore.project import create +from skore.project.create import _create from skore.utils._logger import logger_context def __quickstart( project_name: Union[str, Path], - working_dir: Union[Path, None], overwrite: bool, port: int, open_browser: bool, @@ -26,14 +24,9 @@ def __quickstart( ---------- project_name : Path-like Name of the project to be created, or a relative or absolute path. - working_dir : Path or None - If ``project_name`` is not an absolute path, it will be considered relative to - ``working_dir``. If `project_name` is an absolute path, ``working_dir`` will - have no effect. If set to ``None`` (the default), ``working_dir`` will be re-set - 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. + If ``False``, simply warn that a project already exists. port : int Port at which to bind the UI server. open_browser : bool @@ -43,22 +36,17 @@ def __quickstart( """ with logger_context(logger, verbose): try: - create( + _create( project_name=project_name, - working_dir=working_dir, overwrite=overwrite, verbose=verbose, ) - except ProjectAlreadyExistsError: + except FileExistsError: logger.info( f"Project file '{project_name}' already exists. Skipping creation step." ) - path = ( - Path(project_name) - if working_dir is None - else Path(working_dir, project_name) - ) + path = Path(project_name) __launch( project_name=path, diff --git a/skore/src/skore/exceptions.py b/skore/src/skore/exceptions.py index ba86f4144..8df4456f1 100644 --- a/skore/src/skore/exceptions.py +++ b/skore/src/skore/exceptions.py @@ -16,9 +16,5 @@ class ProjectCreationError(Exception): """Project creation failed.""" -class ProjectAlreadyExistsError(Exception): - """A project with this name already exists.""" - - class ProjectPermissionError(Exception): """Permissions in the directory do not allow creating a file.""" diff --git a/skore/src/skore/project/__init__.py b/skore/src/skore/project/__init__.py index 883fb8b79..be1978581 100644 --- a/skore/src/skore/project/__init__.py +++ b/skore/src/skore/project/__init__.py @@ -1,11 +1,9 @@ """Alias top level function and class of the project submodule.""" -from .create import create -from .load import load +from .open import open from .project import Project __all__ = [ - "create", - "load", + "open", "Project", ] diff --git a/skore/src/skore/project/create.py b/skore/src/skore/project/create.py index bee8449d5..43fab5672 100644 --- a/skore/src/skore/project/create.py +++ b/skore/src/skore/project/create.py @@ -7,11 +7,10 @@ from skore.exceptions import ( InvalidProjectNameError, - ProjectAlreadyExistsError, ProjectCreationError, ProjectPermissionError, ) -from skore.project.load import load +from skore.project.load import _load from skore.project.project import Project, logger from skore.utils._logger import logger_context from skore.view.view import View @@ -58,9 +57,8 @@ def _validate_project_name(project_name: str) -> tuple[bool, Optional[Exception] return True, None -def create( +def _create( project_name: Union[str, Path], - working_dir: Optional[Path] = None, overwrite: bool = False, verbose: bool = False, ) -> Project: @@ -69,12 +67,8 @@ def create( Parameters ---------- project_name : Path-like - Name of the project to be created, or a relative or absolute path. - working_dir : Path or None - If ``project_name`` is not an absolute path, it will be considered relative to - ``working_dir``. If ``project_name`` is an absolute path, ``working_dir`` will - have no effect. If set to ``None`` (the default), ``working_dir`` will be re-set - to the current working directory. + 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. @@ -105,16 +99,11 @@ def create( # If not provided, it will be automatically appended. # If project name is an absolute path, we keep that path - # NOTE: `working_dir` has no effect if `checked_project_name` is absolute - if working_dir is None: - working_dir = Path.cwd() - project_directory = working_dir / ( - project_path.with_name(checked_project_name + ".skore") - ) + project_directory = project_path.with_name(checked_project_name + ".skore") if project_directory.exists(): if not overwrite: - raise ProjectAlreadyExistsError( + 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 " @@ -153,7 +142,7 @@ def create( f"Unable to create project file '{views_dir}'." ) from e - p = load(project_directory) + p = _load(project_directory) p.put_view("default", View(layout=[])) console.rule("[bold cyan]skore[/bold cyan]") diff --git a/skore/src/skore/project/load.py b/skore/src/skore/project/load.py index c78948690..80a9bc0dd 100644 --- a/skore/src/skore/project/load.py +++ b/skore/src/skore/project/load.py @@ -13,24 +13,25 @@ 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.""" - # Transform a project name to a directory path: - # - Resolve relative path to current working directory, - # - Check that the file ends with the ".skore" extension, - # - If not provided, it will be automatically appended, - # - If project name is an absolute path, we keep that path. - +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 ProjectLoadError(f"Project '{path}' does not exist: did you create it?") + raise FileNotFoundError(f"Project '{path}' does not exist: did you create it?") try: - # FIXME should those hardcoded string be factorized somewhere ? + # 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") diff --git a/skore/src/skore/project/open.py b/skore/src/skore/project/open.py new file mode 100644 index 000000000..f8da7bd23 --- /dev/null +++ b/skore/src/skore/project/open.py @@ -0,0 +1,53 @@ +"""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 creates the project if it does not exist, and it overwrites + 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 + Create the project if it does not 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/ui/app.py b/skore/src/skore/ui/app.py index c682eff69..eaae88d03 100644 --- a/skore/src/skore/ui/app.py +++ b/skore/src/skore/ui/app.py @@ -8,7 +8,7 @@ from fastapi.staticfiles import StaticFiles from starlette.types import Lifespan -from skore.project import Project, load +from skore.project import Project, open from skore.ui.dependencies import get_static_path from skore.ui.project_routes import router as project_router @@ -21,7 +21,7 @@ def create_app( # Give the app access to the project if not project: - project = load("project.skore") + project = open("project.skore") app.state.project = project diff --git a/skore/tests/integration/cli/test_create.py b/skore/tests/integration/cli/test_create.py index 984f226c5..6e249b389 100644 --- a/skore/tests/integration/cli/test_create.py +++ b/skore/tests/integration/cli/test_create.py @@ -1,57 +1,43 @@ -import subprocess +import os import pytest +from skore.cli.cli import cli +from skore.exceptions import ProjectCreationError def test_create_project_cli_default_argument(tmp_path): - completed_process = subprocess.run( - f"skore create --working-dir {tmp_path}".split(), capture_output=True - ) - completed_process.check_returncode() + 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): - completed_process = subprocess.run( - f"skore create hello.skore --working-dir {tmp_path}".split(), - capture_output=True, - ) - completed_process.check_returncode() + os.chdir(tmp_path) + cli("create hello.skore".split()) assert (tmp_path / "hello.skore").exists() -def test_create_project_cli_invalid_name(tmp_path): - completed_process = subprocess.run( - f"skore create hello.txt --working-dir {tmp_path}".split(), - capture_output=True, - ) - with pytest.raises(subprocess.CalledProcessError): - completed_process.check_returncode() - assert b"InvalidProjectNameError" in completed_process.stderr +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.""" - completed_process = subprocess.run( - f"skore create --working-dir {tmp_path}".split(), - capture_output=True, - ) - completed_process.check_returncode() + os.chdir(tmp_path) + cli("create".split()) assert (tmp_path / "project.skore").exists() # calling the same command without overwriting should fail - completed_process = subprocess.run( - f"skore create --working-dir {tmp_path}".split(), - capture_output=True, - ) - with pytest.raises(subprocess.CalledProcessError): - completed_process.check_returncode() - assert b"ProjectAlreadyExistsError" in completed_process.stderr + with pytest.raises(FileExistsError): + cli("create".split()) # calling the same command with overwriting should succeed - completed_process = subprocess.run( - f"skore create --working-dir {tmp_path} --overwrite".split(), - capture_output=True, - ) - completed_process.check_returncode() + 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 new file mode 100644 index 000000000..ee2eb26a3 --- /dev/null +++ b/skore/tests/integration/cli/test_quickstart.py @@ -0,0 +1,25 @@ +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/unit/cli/test_quickstart.py b/skore/tests/unit/cli/test_quickstart.py index 81114bde8..40c65f0cb 100644 --- a/skore/tests/unit/cli/test_quickstart.py +++ b/skore/tests/unit/cli/test_quickstart.py @@ -1,28 +1,23 @@ -from pathlib import Path - from skore.cli.cli import cli -def test_quickstart(monkeypatch): +def test_quickstart(monkeypatch, tmp_path): """`quickstart` passes its arguments down to `create` and `launch`.""" create_project_name = None - create_working_dir = None create_overwrite = None create_verbose = None - def fake_create(project_name, working_dir, overwrite, verbose): + def fake_create(project_name, overwrite, verbose): nonlocal create_project_name - nonlocal create_working_dir nonlocal create_overwrite nonlocal create_verbose create_project_name = project_name - create_working_dir = working_dir create_overwrite = overwrite create_verbose = verbose - monkeypatch.setattr("skore.cli.quickstart_command.create", fake_create) + monkeypatch.setattr("skore.cli.quickstart_command._create", fake_create) launch_project_name = None launch_port = None @@ -45,23 +40,20 @@ def fake_launch(project_name, port, open_browser, verbose): cli( [ "quickstart", - "my_project.skore", + str(tmp_path / "my_project.skore"), "--verbose", "--overwrite", - "--working-dir", - "/tmp", "--port", "888", "--no-open-browser", ] ) - assert create_project_name == "my_project.skore" - assert create_working_dir == Path("/tmp") + assert create_project_name == str(tmp_path / "my_project.skore") assert create_overwrite is True assert create_verbose is True - assert launch_project_name == Path("/tmp/my_project.skore") + 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_load.py b/skore/tests/unit/project/test_load.py new file mode 100644 index 000000000..06dd4b602 --- /dev/null +++ b/skore/tests/unit/project/test_load.py @@ -0,0 +1,32 @@ +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 new file mode 100644 index 000000000..9f5bc0d71 --- /dev/null +++ b/skore/tests/unit/project/test_open.py @@ -0,0 +1,93 @@ +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/test_project.py b/skore/tests/unit/project/test_project.py similarity index 90% rename from skore/tests/unit/test_project.py rename to skore/tests/unit/project/test_project.py index 88b256e54..85cd8dbf0 100644 --- a/skore/tests/unit/test_project.py +++ b/skore/tests/unit/project/test_project.py @@ -1,4 +1,3 @@ -import os from io import BytesIO import altair @@ -14,16 +13,9 @@ from sklearn.ensemble import RandomForestClassifier from skore.exceptions import ( InvalidProjectNameError, - ProjectAlreadyExistsError, ProjectCreationError, ) -from skore.project import ( - Project, - create, - load, -) -from skore.project.create import _validate_project_name -from skore.project.load import ProjectLoadError +from skore.project.create import _create, _validate_project_name from skore.view.view import View @@ -147,25 +139,6 @@ def test_put_rf_model(in_memory_project, monkeypatch): assert isinstance(in_memory_project.get("rf_model"), RandomForestClassifier) -def test_load(tmp_path): - with pytest.raises(ProjectLoadError): - load("/empty") - - # 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") - - p = load(project_path) - assert isinstance(p, Project) - - # Test without `.skore` - os.chdir(tmp_path.parent) - p = load(tmp_path.name) - assert isinstance(p, Project) - - def test_put(in_memory_project): in_memory_project.put("key1", 1) in_memory_project.put("key2", 2) @@ -323,30 +296,30 @@ def test_validate_project_name(project_name, expected): @pytest.mark.parametrize("project_name", ["hello", "hello.skore"]) def test_create_project(project_name, tmp_path): - create(project_name, working_dir=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") + _create(tmp_path / "hello") assert (tmp_path / "hello.skore").exists() def test_create_project_fails_if_file_exists(tmp_path): - create(tmp_path / "hello") + _create(tmp_path / "hello") assert (tmp_path / "hello.skore").exists() - with pytest.raises(ProjectAlreadyExistsError): - create(tmp_path / "hello") + with pytest.raises(FileExistsError): + _create(tmp_path / "hello") def test_create_project_fails_if_permission_denied(tmp_path): with pytest.raises(ProjectCreationError): - create("/") + _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(project_name, working_dir=tmp_path) + _create(tmp_path / project_name) diff --git a/sphinx/api.rst b/sphinx/api.rst index 5113e4b1c..4c3b2535c 100644 --- a/sphinx/api.rst +++ b/sphinx/api.rst @@ -21,8 +21,7 @@ These functions and classes are meant for managing a Project. Project item.primitive_item.PrimitiveItem - create - load + open Get assistance when developing ML/DS projects --------------------------------------------- From 3c4b37f25e0ad0404f88fbd76fafb850054468de Mon Sep 17 00:00:00 2001 From: "Matt J." Date: Tue, 7 Jan 2025 17:34:23 +0100 Subject: [PATCH 06/14] feat(UI CrossValidationReporter): Indicate whether metrics are lower/higher-means-better (#1053) This PR adds icon to help understand if metric value is better when higher or better if lower. Fixes #903 # UI preview https://github.com/user-attachments/assets/e6c521e9-5ab9-496c-af34-72bce9f39b0d --- skore-ui/src/assets/fonts/icomoon.eot | Bin 9896 -> 10052 bytes skore-ui/src/assets/fonts/icomoon.svg | 84 +++++----- skore-ui/src/assets/fonts/icomoon.ttf | Bin 9732 -> 9888 bytes skore-ui/src/assets/fonts/icomoon.woff | Bin 9808 -> 9964 bytes skore-ui/src/assets/styles/_icons.css | 110 +++++++------ .../CrossValidationReportResults.vue | 151 +++++++++++------- skore-ui/src/dto.ts | 4 + skore-ui/src/views/ComponentsView.vue | 28 +++- skore-ui/vite.config.ts | 2 +- skore/src/skore/item/cross_validation_item.py | 35 +++- .../cross_validation_helpers.py | 4 +- skore/tests/integration/ui/test_ui.py | 26 ++- .../unit/item/test_cross_validation_item.py | 10 +- 13 files changed, 289 insertions(+), 165 deletions(-) diff --git a/skore-ui/src/assets/fonts/icomoon.eot b/skore-ui/src/assets/fonts/icomoon.eot index 47e6f3cd0e6509bb1028979847660a7b864ce997..5ad979c136213d5c048f3ee69e62a08217518893 100644 GIT binary patch literal 10052 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 Ro7;a88^Nk0_x6Xb{{z)jXLSGo diff --git a/skore-ui/src/assets/fonts/icomoon.svg b/skore-ui/src/assets/fonts/icomoon.svg index 2b139ba70..57ace39bb 100644 --- a/skore-ui/src/assets/fonts/icomoon.svg +++ b/skore-ui/src/assets/fonts/icomoon.svg @@ -7,45 +7,47 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skore-ui/src/assets/fonts/icomoon.ttf b/skore-ui/src/assets/fonts/icomoon.ttf index e5d4280997b26120f49c41c761cc677952e7831a..839e72fb64e911cd63308ccf61b578882456ba81 100644 GIT binary patch literal 9888 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 J+Kmr*{V$kYk{SR2 literal 9732 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 diff --git a/skore-ui/src/assets/fonts/icomoon.woff b/skore-ui/src/assets/fonts/icomoon.woff index 7ed0cf0d070a8f644a5569bd32a5761fdb35bc4b..754a77538cf2b419785c7d1ce0ed2ed632cdb440 100644 GIT binary patch literal 9964 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 SK7qC71TaPjtF;@KdHpYIn3vlC literal 9808 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 nr|$=wgM3E2pqux?W6IzcG&iAM -
{{ result.name }}
+
+ {{ result.name }} + + +
@@ -102,7 +108,17 @@ function isNameTooltipEnabled(index: number) { Fold - {{ column }} + + {{ column }} + + + @@ -140,7 +156,6 @@ function isNameTooltipEnabled(index: number) { } & .result { - overflow: hidden; padding: var(--spacing-8) var(--spacing-10); background-color: var(--color-background-primary); @@ -181,10 +196,17 @@ function isNameTooltipEnabled(index: number) { font-size: var(--font-size-xs); } } + + & .icon { + color: var(--color-icon-tertiary); + vertical-align: middle; + } } } & .tabular-results { + max-width: 100%; + & .header { display: flex; align-items: center; @@ -208,63 +230,37 @@ function isNameTooltipEnabled(index: number) { } } - & table { - --fold-column-width: 70px; - - width: 100%; - border-collapse: collapse; - text-align: right; - - & thead tr th { - padding: var(--spacing-6) var(--spacing-10); - border: solid var(--stroke-width-md) var(--color-stroke-background-primary); - border-bottom-color: var(--color-background-primary); - background-color: var(--color-background-secondary); - color: var(--color-text-primary); - font-weight: var(--font-weight-medium); - - &:first-child { - position: sticky; - left: 0; - width: var(--fold-column-width); - border-left: none; - text-align: center; - } + & .result { + overflow: hidden; + max-width: 100%; - &:last-child { - border-right: none; - } - } + & table { + --fold-column-width: 70px; - & tbody tr { - position: relative; - color: var(--color-text-primary); - font-weight: var(--font-weight-regular); + min-width: 100%; + border-collapse: collapse; + text-align: right; - & td { + & thead tr th { padding: var(--spacing-6) var(--spacing-10); border: solid var(--stroke-width-md) var(--color-stroke-background-primary); + border-bottom-color: var(--color-background-primary); + background-color: var(--color-background-secondary); + color: var(--color-text-primary); + font-weight: var(--font-weight-medium); + + /* stylelint-disable-next-line no-descending-specificity */ + & .icon { + color: var(--color-icon-tertiary); + vertical-align: middle; + } &:first-child { position: sticky; - z-index: 2; left: 0; width: var(--fold-column-width); - border-bottom-color: var(--color-background-primary); border-left: none; - background-color: var(--color-background-secondary); - font-weight: var(--font-weight-medium); - text-align: left; - - &::after { - position: absolute; - top: 0; - right: -3px; - width: 3px; - height: 100%; - background: linear-gradient(to right, var(--color-background-secondary), transparent); - content: " "; - } + text-align: center; } &:last-child { @@ -272,10 +268,51 @@ function isNameTooltipEnabled(index: number) { } } - &:last-child { + & tbody tr { + position: relative; + color: var(--color-text-primary); + font-weight: var(--font-weight-regular); + & td { - border-bottom: none; - border-bottom-left-radius: var(--radius-xs); + padding: var(--spacing-6) var(--spacing-10); + border: solid var(--stroke-width-md) var(--color-stroke-background-primary); + + &:first-child { + position: sticky; + z-index: 2; + left: 0; + width: var(--fold-column-width); + border-bottom-color: var(--color-background-primary); + border-left: none; + background-color: var(--color-background-secondary); + font-weight: var(--font-weight-medium); + text-align: left; + + &::after { + position: absolute; + top: 0; + right: -3px; + width: 3px; + height: 100%; + background: linear-gradient( + to right, + var(--color-background-secondary), + transparent + ); + content: " "; + } + } + + &:last-child { + border-right: none; + } + } + + &:last-child { + & td { + border-bottom: none; + border-bottom-left-radius: var(--radius-xs); + } } } } diff --git a/skore-ui/src/dto.ts b/skore-ui/src/dto.ts index 35645d866..f3713e49d 100644 --- a/skore-ui/src/dto.ts +++ b/skore-ui/src/dto.ts @@ -31,18 +31,22 @@ export interface ProjectDto { */ export type ActivityFeedDto = ProjectItemDto[]; +export type Favorability = "greater_is_better" | "lower_is_better" | "unknown"; + export interface ScalarResultDto { name: string; value: number; stddev?: number; label?: string; description?: string; + favorability: Favorability; } export interface TabularResultDto { name: string; columns: any[]; data: any[][]; + favorability: Favorability[]; } export interface PrimaryResultsDto { diff --git a/skore-ui/src/views/ComponentsView.vue b/skore-ui/src/views/ComponentsView.vue index 96d3043e2..4409603e1 100644 --- a/skore-ui/src/views/ComponentsView.vue +++ b/skore-ui/src/views/ComponentsView.vue @@ -245,14 +245,20 @@ const isCached = ref(false); const results: PrimaryResultsDto = { scalarResults: [ - { name: "toto", value: 4.32 }, - { name: "tata", value: 4.32 }, - { name: "titi", value: 4.32, stddev: 1 }, - { name: "tutu", value: 4.32 }, - { name: "stab", value: 0.4, label: "Good" }, - { name: "titi", value: 4.32, stddev: 1 }, - { name: "tutu", value: 4.32 }, - { name: "stab", value: 0.9, label: "Good", description: "your blabla is good" }, + { name: "toto", value: 4.32, favorability: "greater_is_better" }, + { name: "tata", value: 4.32, favorability: "greater_is_better" }, + { name: "titi", value: 4.32, stddev: 1, favorability: "greater_is_better" }, + { name: "tutu", value: 4.32, favorability: "greater_is_better" }, + { name: "stab", value: 0.4, label: "Good", favorability: "greater_is_better" }, + { name: "titi", value: 4.32, stddev: 1, favorability: "greater_is_better" }, + { name: "tutu", value: 4.32, favorability: "greater_is_better" }, + { + name: "stab", + value: 0.9, + label: "Good", + description: "your blabla is good", + favorability: "greater_is_better", + }, ], tabularResults: [ { @@ -266,6 +272,9 @@ const results: PrimaryResultsDto = { Array.from({ length: 50 }, () => Math.random().toFixed(4)), Array.from({ length: 50 }, () => Math.random().toFixed(4)), ], + favorability: Array.from({ length: 50 }, (_, i) => + i % 2 === 0 ? "greater_is_better" : "lower_is_better" + ), }, { name: "b", @@ -276,6 +285,7 @@ const results: PrimaryResultsDto = { [0.8, 0.4, 0.5, 0.6], [0.8, 0.4, 0.5, 0.6], ], + favorability: ["greater_is_better", "greater_is_better", "lower_is_better"], }, ], }; @@ -1267,6 +1277,8 @@ const toggleModel = ref(true);
icon-square-cursor
icon-moon
icon-sun
+
icon-ascending-arrow
+
icon-descending-arrow
diff --git a/skore-ui/vite.config.ts b/skore-ui/vite.config.ts index b79caa712..8594fa2c6 100644 --- a/skore-ui/vite.config.ts +++ b/skore-ui/vite.config.ts @@ -37,7 +37,7 @@ export default defineConfig({ setupFiles: ["./vitest.setup.ts"], }, build: { - assetsInlineLimit(filePath, content) { + assetsInlineLimit(filePath) { const fontExtensions = ["ttf", "woff", "woff2", "svg", "eot"]; return fontExtensions.some((ext) => filePath.includes(ext)); }, diff --git a/skore/src/skore/item/cross_validation_item.py b/skore/src/skore/item/cross_validation_item.py index e4e4c4a2a..e4873f3a0 100644 --- a/skore/src/skore/item/cross_validation_item.py +++ b/skore/src/skore/item/cross_validation_item.py @@ -14,7 +14,7 @@ import re import statistics from functools import cached_property -from typing import TYPE_CHECKING, Any, TypedDict, Union +from typing import TYPE_CHECKING, Any, Literal, TypedDict, Union import numpy import plotly.graph_objects @@ -66,6 +66,34 @@ def _metric_title(metric): return title +def _metric_favorability( + metric: str, +) -> Literal["greater_is_better", "lower_is_better", "unknown"]: + greater_is_better_metrics = ( + "r2", + "test_r2", + "roc_auc", + "recall", + "recall_weighted", + "precision", + "precision_weighted", + "roc_auc_ovr_weighted", + ) + lower_is_better_metrics = ("fit_time", "score_time") + + if metric.endswith("_score") or metric in greater_is_better_metrics: + return "greater_is_better" + if ( + metric.endswith("_error") + or metric.endswith("_loss") + or metric.endswith("_deviance") + or metric in lower_is_better_metrics + ): + return "lower_is_better" + + return "unknown" + + def _params_to_str(estimator_info) -> str: params_list = [] for k, v in estimator_info["params"].items(): @@ -148,10 +176,12 @@ def as_serializable_dict(self): cv_results = copy.deepcopy(self.cv_results_serialized) cv_results.pop("indices", None) + metrics_names = list(cv_results.keys()) tabular_results = { "name": "Cross validation results", - "columns": list(cv_results.keys()), + "columns": metrics_names, "data": list(zip(*cv_results.values())), + "favorability": [_metric_favorability(m) for m in metrics_names], } # Get scalar results (summary statistics of the cv results) @@ -160,6 +190,7 @@ def as_serializable_dict(self): "name": _metric_title(k), "value": statistics.mean(v), "stddev": statistics.stdev(v), + "favorability": _metric_favorability(k), } for k, v in cv_results.items() ] diff --git a/skore/src/skore/sklearn/cross_validation/cross_validation_helpers.py b/skore/src/skore/sklearn/cross_validation/cross_validation_helpers.py index a4af04fd5..97a46d61e 100644 --- a/skore/src/skore/sklearn/cross_validation/cross_validation_helpers.py +++ b/skore/src/skore/sklearn/cross_validation/cross_validation_helpers.py @@ -1,11 +1,13 @@ """Helpers for enhancing the cross-validation manipulation.""" +from typing import Any + from sklearn import metrics from skore.sklearn.find_ml_task import _find_ml_task -def _get_scorers_to_add(estimator, y) -> list[str]: +def _get_scorers_to_add(estimator, y) -> dict[str, Any]: """Get a list of scorers based on ``estimator`` and ``y``. Parameters diff --git a/skore/tests/integration/ui/test_ui.py b/skore/tests/integration/ui/test_ui.py index f467ff699..863400589 100644 --- a/skore/tests/integration/ui/test_ui.py +++ b/skore/tests/integration/ui/test_ui.py @@ -210,9 +210,24 @@ def prepare_cv(): "media_type": "application/vnd.skore.cross_validation+json", "value": { "scalar_results": [ - {"name": "Mean test score", "value": 1.0, "stddev": 0.0}, - {"name": "Mean score time (seconds)", "value": 1.0, "stddev": 0.0}, - {"name": "Mean fit time (seconds)", "value": 1.0, "stddev": 0.0}, + { + "name": "Mean test score", + "value": 1.0, + "stddev": 0.0, + "favorability": "greater_is_better", + }, + { + "name": "Mean score time (seconds)", + "value": 1.0, + "stddev": 0.0, + "favorability": "lower_is_better", + }, + { + "name": "Mean fit time (seconds)", + "value": 1.0, + "stddev": 0.0, + "favorability": "lower_is_better", + }, ], "tabular_results": [ { @@ -225,6 +240,11 @@ def prepare_cv(): [1.0, 1.0, 1.0], [1.0, 1.0, 1.0], ], + "favorability": [ + "greater_is_better", + "lower_is_better", + "lower_is_better", + ], } ], "plots": [{"name": "compare_scores", "value": {}}], diff --git a/skore/tests/unit/item/test_cross_validation_item.py b/skore/tests/unit/item/test_cross_validation_item.py index 6ee82a3a7..bd9da08d6 100644 --- a/skore/tests/unit/item/test_cross_validation_item.py +++ b/skore/tests/unit/item/test_cross_validation_item.py @@ -122,12 +122,20 @@ def test_get_serializable_dict(self, monkeypatch, mock_nowstr): assert serializable["updated_at"] == mock_nowstr assert serializable["created_at"] == mock_nowstr assert serializable["value"]["scalar_results"] == [ - {"name": "Mean test score", "value": 2, "stddev": 1.0} + { + "name": "Mean test score", + "value": 2, + "stddev": 1.0, + "favorability": "greater_is_better", + } ] assert serializable["value"]["tabular_results"] == [ { "name": "Cross validation results", "columns": ["test_score"], "data": [(1,), (2,), (3,)], + "favorability": [ + "greater_is_better", + ], } ] From 73f6d913899edf4b2cd7a3aa9988f9b6e729f501 Mon Sep 17 00:00:00 2001 From: Guillaume Lemaitre Date: Wed, 8 Jan 2025 09:25:59 +0100 Subject: [PATCH 07/14] feat(cli): Improve colors in CLI (#960) --- skore/src/skore/cli/cli.py | 3 +- skore/src/skore/cli/color_format.py | 106 ++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 skore/src/skore/cli/color_format.py diff --git a/skore/src/skore/cli/cli.py b/skore/src/skore/cli/cli.py index f66eb269d..0a7631bf8 100644 --- a/skore/src/skore/cli/cli.py +++ b/skore/src/skore/cli/cli.py @@ -3,6 +3,7 @@ import argparse 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 @@ -10,7 +11,7 @@ def cli(args: list[str]): """CLI for Skore.""" - parser = argparse.ArgumentParser(prog="skore") + parser = ColorArgumentParser(prog="skore") parser.add_argument( "--version", action="version", version=f"%(prog)s {version('skore')}" diff --git a/skore/src/skore/cli/color_format.py b/skore/src/skore/cli/color_format.py new file mode 100644 index 000000000..db510d649 --- /dev/null +++ b/skore/src/skore/cli/color_format.py @@ -0,0 +1,106 @@ +"""Custom help formatter for the CLI.""" + +import re +import shutil +from argparse import ArgumentParser, HelpFormatter + +from rich.console import Console +from rich.theme import Theme + +skore_console_theme = Theme( + { + "repr.str": "cyan", + "rule.line": "orange1", + "repr.url": "orange1", + } +) + + +class RichColorHelpFormatter(HelpFormatter): + """Custom help formatter for the CLI.""" + + def __init__(self, prog, indent_increment=2, max_help_position=24, width=None): + width = shutil.get_terminal_size()[0] if width is None else width + super().__init__(prog, indent_increment, max_help_position, width) + self.console = Console(theme=skore_console_theme) + + def _format_action_invocation(self, action): + """Format the action invocation (flags and arguments).""" + if not action.option_strings: + metavar = self._metavar_formatter(action, action.dest)(1)[0] + return metavar + else: + parts = [] + # Format short options + if action.option_strings: + parts.extend( + f"[cyan bold]{opt}[/cyan bold]" for opt in action.option_strings + ) + # Format argument + if action.nargs != 0: + default = self._get_default_metavar_for_optional(action) + args_string = self._format_args(action, default) + parts.append(f"[orange1 bold]{args_string}[/orange1 bold]") + + return " ".join(parts) + + def _format_usage(self, usage, actions, groups, prefix): + """Format the usage line.""" + if prefix is None: + prefix = "usage: " + + # Format the usage line + formatted = super()._format_usage(usage, actions, groups, prefix) + + # Apply rich formatting + formatted = re.sub(r"usage:", "[orange1 bold]usage:[/orange1 bold]", formatted) + formatted = re.sub( + r"(?<=\[)[A-Z_]+(?=\])", lambda m: f"[cyan]{m.group()}[/cyan]", formatted + ) + + return formatted + + def format_help(self): + """Format the help message.""" + help_text = super().format_help() + + # Format section headers + help_text = re.sub( + r"^([a-zA-Z ]+ arguments:)$", + r"[dim]\1[/dim]", + help_text, + flags=re.MULTILINE, + ) + + # Format default values + help_text = re.sub( + r"\(default: .*?\)", lambda m: f"[dim]{m.group()}[/dim]", help_text + ) + + # Color the subcommands in cyan + help_text = re.sub( + r"(?<=\s)(launch|create|quickstart)(?=\s+)", + r"[cyan bold]\1[/cyan bold]", + help_text, + ) + + # Color "options" in orange1 + help_text = re.sub( + r"(?<=\s)(options)(?=:)", + r"[orange1]\1[/orange1]", + help_text, + ) + + return help_text + + +class ColorArgumentParser(ArgumentParser): + """Custom argument parser for the CLI.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs, formatter_class=RichColorHelpFormatter) + + def print_help(self, file=None): + """Print the help message.""" + console = Console(file=file) + console.print(self.format_help()) From dae1b5382c0a082a13d52c345d3ffe3d403edcc9 Mon Sep 17 00:00:00 2001 From: "Matt J." Date: Wed, 8 Jan 2025 10:13:50 +0100 Subject: [PATCH 08/14] feat(ui): Add tooltips on favorability icons (#1054) This PR adds tooltips on favorability icons. --- .../CrossValidationReportResults.vue | 28 +++--------- .../src/components/MetricFavorability.vue | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 22 deletions(-) create mode 100644 skore-ui/src/components/MetricFavorability.vue diff --git a/skore-ui/src/components/CrossValidationReportResults.vue b/skore-ui/src/components/CrossValidationReportResults.vue index 7ae45db63..ee48d77cc 100644 --- a/skore-ui/src/components/CrossValidationReportResults.vue +++ b/skore-ui/src/components/CrossValidationReportResults.vue @@ -2,6 +2,10 @@ import Simplebar from "simplebar-vue"; import { computed, ref, useTemplateRef } from "vue"; +import DropdownButton from "@/components/DropdownButton.vue"; +import FloatingTooltip from "@/components/FloatingTooltip.vue"; +import MetricFavorability from "@/components/MetricFavorability.vue"; +import StaticRange from "@/components/StaticRange.vue"; import type { PrimaryResultsDto, TabularResultDto } from "@/dto"; import { isElementOverflowing } from "@/services/utils"; @@ -48,14 +52,7 @@ function isNameTooltipEnabled(index: number) {
{{ result.name }} - - +