Skip to content

Commit

Permalink
feat: Make open to spin-off UI server and to be used in CLI and not…
Browse files Browse the repository at this point in the history
…ebook (#1157)

closes #1002 

This PR proposed to resolve the issue discussed in #1002:

- `open` should provide the possibility to spin-off a UI server
- `open` can therefore be used in the notebook and with the CLI
- `kill` to kill all alive servers (normally we tested that we should
not have some case or zombie processes).

So in short, we allow for the following:

```python
import skore

project = skore.open("my_project")
project.shutdown_web_ui()
```

### Implementation details

We launch the UI in a separate process. Why process: if we would attach
to the async loop for the notebook or run in a separate thread from the
main process, we might get into trouble when this main process starts to
execute large computation making the UI unresponsive.

So we are starting a new process with the webserver inside. For the
moment, we expect to have 1 skore project associated with 1 skore UI
server. So we don't allow to spin a new webserver if one already exist
and is associated with a running skore project. Instead, we provide the
link to the server.

Also, we have a `rejoin` mechanism: if a project was closed but the UI
was not, then when opening a project, we reattach the web server to it.

Finally, we found with @rouk1 that you have the following use-cases:

- CI/CD: you want `serve=False` when calling `open`
- standalone script: You might be interested in `serve=True` and in this
case, you most probably want to keep the webs server alive (so deamon +
atexit is not what we want)
- jupyter notebook: You might be interested in `serve=True` and you will
be interested to have access to the UI the time that the notebook is
alive. The current setup will kill the web server.

So we added a `keep_alive` option that detect if you are in a specific
environment and choose a sensible default but it can be easily
overwritten.

### Notes

Some of the covering is not really working with the multiprocessing
because we explicitly check for the console message in the test and the
coverage mentioned that we don't cover it.

### TODO

- [x] write tests for the `keep_alive` option
- [x] write tests for the `kill` cli
  • Loading branch information
glemaitre authored Jan 30, 2025
1 parent 4f72706 commit eafd8cb
Show file tree
Hide file tree
Showing 18 changed files with 1,114 additions and 141 deletions.
5 changes: 5 additions & 0 deletions skore/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ dependencies = [
"scikit-learn",
"skops",
"uvicorn",
"platformdirs",
"psutil",
]
classifiers = [
"Intended Audience :: Science/Research",
Expand Down Expand Up @@ -113,12 +115,15 @@ addopts = [
"--ignore=doc",
"--ignore=examples",
"--ignore=notebooks",
"--dist=loadscope",
]
doctest_optionflags = ["ELLIPSIS", "NORMALIZE_WHITESPACE"]

[tool.coverage.run]
branch = true
source = ["skore"]
concurrency = ["thread", "multiprocessing"]
parallel = true

[tool.coverage.report]
omit = ["*/externals/*", "src/*", "tests/*"]
Expand Down
3 changes: 2 additions & 1 deletion skore/src/skore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from rich.console import Console
from rich.theme import Theme

from skore.project import Project
from skore.project import Project, open
from skore.sklearn import (
CrossValidationReport,
CrossValidationReporter,
Expand All @@ -20,6 +20,7 @@
"CrossValidationReport",
"EstimatorReport",
"Project",
"open",
"show_versions",
"train_test_split",
]
Expand Down
84 changes: 45 additions & 39 deletions skore/src/skore/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,61 +4,67 @@
from importlib.metadata import version

from skore.cli.color_format import ColorArgumentParser
from skore.ui.launch import launch
from skore.project import open
from skore.project._launch import _kill_all_servers


def argumentparser():
"""Argument parser for the Skore CLI."""
parser = ColorArgumentParser(
prog="skore-ui",
description="Launch the skore UI on a defined skore project.",
)
def cli(args: list[str]):
"""CLI for Skore."""
parser = ColorArgumentParser(prog="skore-ui")

parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {version('skore')}",
"--version", action="version", version=f"%(prog)s {version('skore')}"
)

parser.add_argument(
"project_name",
help="the name or path of the project to be created or opened",
)
subparsers = parser.add_subparsers(dest="subcommand")

parser.add_argument(
# open a skore project
parser_open = subparsers.add_parser(
"open", help="Open a skore project and start the UI"
)
parser_open.add_argument(
"project_path",
nargs="?",
help="the name or path of the project to be opened",
)
parser_open.add_argument(
"--serve",
action=argparse.BooleanOptionalAction,
help=("whether to serve the project (default: %(default)s)"),
default=True,
)
parser_open.add_argument(
"--port",
type=int,
help="the port at which to bind the UI server (default: %(default)s)",
default=22140,
default=None,
)

parser.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_open.add_argument(
"--verbose",
action="store_true",
help="increase logging verbosity",
)

parser.add_argument(
# kill all UI servers
parser_kill = subparsers.add_parser("kill", help="Kill all UI servers")
parser_kill.add_argument(
"--verbose",
action="store_true",
help="increase logging verbosity",
)

return parser


def cli(args: list[str]):
"""CLI for Skore."""
parser = argumentparser()
arguments = parser.parse_args(args)
parsed_args: argparse.Namespace = parser.parse_args(args)

launch(
project_name=arguments.project_name,
port=arguments.port,
open_browser=arguments.open_browser,
verbose=arguments.verbose,
)
if parsed_args.subcommand == "open":
open(
project_path=parsed_args.project_path,
if_exists="load",
serve=parsed_args.serve,
keep_alive=True,
port=parsed_args.port,
verbose=parsed_args.verbose,
)
elif parsed_args.subcommand == "kill":
_kill_all_servers(verbose=parsed_args.verbose)
else:
parser.print_help()
2 changes: 1 addition & 1 deletion skore/src/skore/cli/color_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def format_help(self):

# Color the subcommands in cyan
help_text = re.sub(
r"(?<=\s)(launch|create|quickstart)(?=\s+)",
r"(?<=\s)(create|open|kill)(?=\s+)",
r"[cyan bold]\1[/cyan bold]",
help_text,
)
Expand Down
4 changes: 4 additions & 0 deletions skore/src/skore/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ class ProjectCreationError(Exception):

class ProjectPermissionError(Exception):
"""Permissions in the directory do not allow creating a file."""


class ProjectLoadError(Exception):
"""Failed to load project."""
2 changes: 2 additions & 0 deletions skore/src/skore/project/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""Alias top level function and class of the project submodule."""

from ._open import open
from .project import Project

__all__ = [
"open",
"Project",
]
Loading

0 comments on commit eafd8cb

Please sign in to comment.