Skip to content

Commit 81623ef

Browse files
authored
Add nu CLI interface (#174)
Adds an executable CLI interface `nu`. Explore with `nu install-completion` or `nu --help`. For system-wide functionality install with `pipx`.
1 parent 885274a commit 81623ef

32 files changed

+872
-51
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to the [Nucleus Python Client](https://github.com/scaleapi/n
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [0.6.0](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.6.0) - 2021-01-11
8+
9+
### Added
10+
- Nucleus CLI interface `nu`. Installation instructions are in the `README.md`.
11+
712
## [0.5.4](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.5.4) - 2022-01-28
813

914
### Added

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,29 @@ Nucleus is a new way—the right way—to develop ML models, helping us move awa
1717

1818
`$ pip install scale-nucleus`
1919

20+
21+
## CLI installation
22+
We recommend installing the CLI via `pipx` (https://pypa.github.io/pipx/installation/). This makes sure that
23+
the CLI does not interfere with you system packages and is accessible from your favorite terminal.
24+
25+
For MacOS:
26+
```bash
27+
brew install pipx
28+
pipx ensurepath
29+
pipx install scale-nucleus
30+
# Optional installation of shell completion (for bash, zsh or fish)
31+
nu install-completions
32+
```
33+
34+
Otherwise, install via pip (requires pip 19.0 or later):
35+
```bash
36+
python3 -m pip install --user pipx
37+
python3 -m pipx ensurepath
38+
python3 -m pipx install scale-nucleus
39+
# Optional installation of shell completion (for bash, zsh or fish)
40+
nu install-completions
41+
```
42+
2043
## Common issues/FAQ
2144

2245
### Outdated Client

cli/client.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import functools
2+
import os
3+
4+
import nucleus
5+
6+
7+
@functools.lru_cache()
8+
def init_client():
9+
api_key = os.environ.get("NUCLEUS_API_KEY", None)
10+
if api_key:
11+
client = nucleus.NucleusClient(api_key)
12+
else:
13+
raise RuntimeError("No NUCLEUS_API_KEY set")
14+
return client

cli/datasets.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import click
2+
from rich.console import Console
3+
from rich.table import Column, Table
4+
5+
from cli.client import init_client
6+
from cli.helpers.nucleus_url import nucleus_url
7+
from cli.helpers.web_helper import launch_web_or_invoke
8+
9+
10+
@click.group("datasets", invoke_without_command=True)
11+
@click.option("--web", is_flag=True, help="Launch browser")
12+
@click.pass_context
13+
def datasets(ctx, web):
14+
"""Datasets are the base collections of items in Nucleus
15+
16+
https://dashboard.scale.com/nucleus/datasets
17+
"""
18+
launch_web_or_invoke(
19+
sub_url="datasets", ctx=ctx, launch_browser=web, command=list_datasets
20+
)
21+
22+
23+
@datasets.command("list")
24+
@click.option(
25+
"-m", "--machine-readable", is_flag=True, help="Removes pretty printing"
26+
)
27+
def list_datasets(machine_readable):
28+
"""List all available Datasets"""
29+
console = Console()
30+
with console.status("Finding your Datasets!", spinner="dots4"):
31+
client = init_client()
32+
all_datasets = client.datasets
33+
if machine_readable:
34+
table_params = {"box": None, "pad_edge": False}
35+
else:
36+
table_params = {
37+
"title": ":fire: Datasets",
38+
"title_justify": "left",
39+
}
40+
41+
table = Table(
42+
"id", "Name", Column("url", overflow="fold"), **table_params
43+
)
44+
for ds in all_datasets:
45+
table.add_row(ds.id, ds.name, nucleus_url(ds.id))
46+
console.print(table)
47+
48+
49+
@datasets.command("delete")
50+
@click.option("--id", prompt=True)
51+
@click.option(
52+
"--no-confirm-deletion",
53+
is_flag=True,
54+
help="WARNING: No confirmation for deletion",
55+
)
56+
@click.pass_context
57+
def delete_dataset(ctx, id, no_confirm_deletion):
58+
"""Delete a Dataset"""
59+
console = Console()
60+
client = init_client()
61+
id = id.strip()
62+
dataset = client.get_dataset(id)
63+
delete_string = ""
64+
if not no_confirm_deletion:
65+
delete_string = click.prompt(
66+
click.style(
67+
f"Type 'DELETE' to delete dataset: {dataset}", fg="red"
68+
)
69+
)
70+
if no_confirm_deletion or delete_string == "DELETE":
71+
client.delete_dataset(dataset.id)
72+
console.print(f":fire: :anguished: Deleted {id}")
73+
else:
74+
console.print(
75+
f":rotating_light: Refusing to delete {id}. Received '{delete_string}' instead of 'DELETE'"
76+
)
77+
ctx.abort()

cli/helpers/__init__.py

Whitespace-only changes.

cli/helpers/nucleus_url.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import os
2+
from urllib.parse import urljoin
3+
4+
5+
def nucleus_url(sub_path: str):
6+
nucleus_base = os.environ.get(
7+
"NUCLEUS_DASHBOARD", "https://dashboard.scale.com/nucleus/"
8+
)
9+
extra_params = os.environ.get("NUCLEUS_DASH_PARAMS", "")
10+
return urljoin(nucleus_base, sub_path.lstrip("/") + extra_params)

cli/helpers/web_helper.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import click
2+
3+
from cli.helpers.nucleus_url import nucleus_url
4+
5+
6+
def launch_web_or_show_help(
7+
sub_url: str, ctx: click.Context, launch_browser: bool
8+
):
9+
""" Launches the sub_url (composed with nuclues_url(sub_url)) in the browser if requested"""
10+
if not ctx.invoked_subcommand:
11+
if launch_browser:
12+
url = nucleus_url(sub_url)
13+
click.launch(url)
14+
else:
15+
click.echo(ctx.get_help())
16+
else:
17+
if launch_browser:
18+
click.echo(click.style("--web does not work with sub-commands"))
19+
ctx.abort()
20+
21+
22+
def launch_web_or_invoke(
23+
sub_url: str,
24+
ctx: click.Context,
25+
launch_browser: bool,
26+
command: click.BaseCommand,
27+
):
28+
"""Launches the sub_url (composed with nuclues_url(sub_url)) in the browser if requested, otherwise invokes
29+
the passed command
30+
"""
31+
if not ctx.invoked_subcommand:
32+
if launch_browser:
33+
url = nucleus_url(sub_url)
34+
click.launch(url)
35+
else:
36+
ctx.invoke(command)
37+
else:
38+
if launch_browser:
39+
click.echo(click.style("--web does not work with sub-commands"))
40+
ctx.abort()

cli/install_completion.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import os
2+
import shutil
3+
4+
import click
5+
from shellingham import detect_shell
6+
7+
8+
@click.command("install-completion")
9+
def install_completion():
10+
"""Install shell completion script to your rc file"""
11+
shell, _ = detect_shell()
12+
if shell == "zsh":
13+
rc_path = "~/.zshrc"
14+
append_to_file = 'eval "$(_NU_COMPLETE=zsh_source nu)"'
15+
elif shell == "bash":
16+
rc_path = "~/.bashrc"
17+
append_to_file = 'eval "$(_NU_COMPLETE=bash_source nu)"'
18+
elif shell == "fish":
19+
rc_path = "~/.config/fish/completions/foo-bar.fish"
20+
append_to_file = "eval (env _NU_COMPLETE=fish_source nu)"
21+
else:
22+
raise RuntimeError(f"Unsupported shell {shell} for completions")
23+
24+
rc_path_expanded = os.path.expanduser(rc_path)
25+
rc_bak = f"{rc_path_expanded}.bak"
26+
shutil.copy(rc_path_expanded, rc_bak)
27+
click.echo(f"Backed up {rc_path} to {rc_bak}")
28+
with open(rc_path_expanded, mode="a") as rc_file:
29+
rc_file.write("\n")
30+
rc_file.write("# Shell completion for nu\n")
31+
rc_file.write(append_to_file)
32+
click.echo(f"Completion script added to {rc_path}")
33+
click.echo(f"Don't forget to `source {rc_path}")

cli/jobs.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import click
2+
from rich.live import Live
3+
from rich.spinner import Spinner
4+
from rich.table import Column, Table
5+
6+
from cli.client import init_client
7+
from cli.helpers.web_helper import launch_web_or_invoke
8+
9+
10+
@click.group("jobs", invoke_without_command=True)
11+
@click.option("--web", is_flag=True, help="Launch browser")
12+
@click.pass_context
13+
def jobs(ctx, web):
14+
"""Jobs are a wrapper around various long-running tasks withing Nucleus
15+
16+
https://dashboard.scale.com/nucleus/jobs
17+
"""
18+
launch_web_or_invoke("jobs", ctx, web, list_jobs)
19+
20+
21+
@jobs.command("list")
22+
def list_jobs():
23+
"""List all of your Jobs"""
24+
client = init_client()
25+
table = Table(
26+
Column("id", overflow="fold", min_width=24),
27+
"status",
28+
"type",
29+
"created at",
30+
title=":satellite: Jobs",
31+
title_justify="left",
32+
)
33+
with Live(Spinner("dots4", text="Finding your Jobs!")) as live:
34+
all_jobs = client.jobs
35+
for job in all_jobs:
36+
table.add_row(
37+
job.job_id,
38+
job.job_last_known_status,
39+
job.job_type,
40+
job.job_creation_time,
41+
)
42+
live.update(table)

cli/models.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import click
2+
from rich.console import Console
3+
from rich.table import Column, Table
4+
5+
from cli.client import init_client
6+
from cli.helpers.nucleus_url import nucleus_url
7+
from cli.helpers.web_helper import launch_web_or_invoke
8+
9+
10+
@click.group("models", invoke_without_command=True)
11+
@click.option("--web", is_flag=True, help="Launch browser")
12+
@click.pass_context
13+
def models(ctx, web):
14+
"""Models help you store and access your ML model data
15+
16+
https://dashboard.scale.com/nucleus/models
17+
"""
18+
launch_web_or_invoke("models", ctx, web, list_models)
19+
20+
21+
@models.command("list")
22+
def list_models():
23+
"""List your Models"""
24+
console = Console()
25+
with console.status("Finding your Models!", spinner="dots4"):
26+
client = init_client()
27+
table = Table(
28+
Column("id", overflow="fold", min_width=24),
29+
"name",
30+
Column("url", overflow="fold"),
31+
)
32+
models = client.models
33+
for m in models:
34+
table.add_row(m.id, m.name, nucleus_url(m.id))
35+
console.print(table)

cli/nu.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import click
2+
3+
from cli.datasets import datasets
4+
from cli.helpers.web_helper import launch_web_or_show_help
5+
from cli.install_completion import install_completion
6+
from cli.jobs import jobs
7+
from cli.models import models
8+
from cli.reference import reference
9+
from cli.slices import slices
10+
from cli.tests import tests
11+
12+
13+
@click.group("cli", invoke_without_command=True)
14+
@click.option("--web", is_flag=True, help="Launch browser")
15+
@click.pass_context
16+
def nu(ctx, web):
17+
"""Nucleus CLI (nu)
18+
19+
\b
20+
███╗ ██╗██╗ ██╗ ██████╗██╗ ███████╗██╗ ██╗███████╗
21+
████╗ ██║██║ ██║██╔════╝██║ ██╔════╝██║ ██║██╔════╝
22+
██╔██╗ ██║██║ ██║██║ ██║ █████╗ ██║ ██║███████╗
23+
██║╚██╗██║██║ ██║██║ ██║ ██╔══╝ ██║ ██║╚════██║
24+
██║ ╚████║╚██████╔╝╚██████╗███████╗███████╗╚██████╔╝███████║
25+
╚═╝ ╚═══╝ ╚═════╝ ╚═════╝╚══════╝╚══════╝ ╚═════╝ ╚══════╝
26+
27+
`nu` is a command line interface to interact with Scale Nucleus (https://dashboard.scale.com/nucleus)
28+
"""
29+
launch_web_or_show_help(sub_url="", ctx=ctx, launch_browser=web)
30+
31+
32+
nu.add_command(datasets) # type: ignore
33+
nu.add_command(install_completion) # type: ignore
34+
nu.add_command(jobs) # type: ignore
35+
nu.add_command(models) # type: ignore
36+
nu.add_command(reference) # type: ignore
37+
nu.add_command(slices) # type: ignore
38+
nu.add_command(tests) # type: ignore
39+
40+
if __name__ == "__main__":
41+
"""To debug, run this script followed by request command tree e.g. `cli/nu.py datasets list`"""
42+
nu()

cli/reference.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import click
2+
3+
4+
# NOTE: Named reference instead of docs to not clash with dataset autocomplete
5+
@click.command("reference")
6+
def reference():
7+
""" View the Nucleus reference documentation in the browser"""
8+
click.launch("https://nucleus.scale.com/docs")

0 commit comments

Comments
 (0)