Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
18293ab
feat: add shell completion for jmp-admin and shared completion factory
raballew Apr 7, 2026
cb5674e
fix: add unit tests for make_completion_command factory in cli-common
raballew Apr 7, 2026
7364afd
feat: add shell completion for jmp, jmp-admin and j in jmp shell
raballew Apr 7, 2026
724391d
fix: address shell completion review findings
raballew Apr 7, 2026
3035ec5
fix: address remaining shell completion review findings
raballew Apr 7, 2026
fefea1f
fix: address CodeRabbit review findings on shell completion
raballew Apr 8, 2026
1358556
fix: initialize zsh completion system before using compdef
raballew Apr 8, 2026
ce5026f
fix: address nitpick review findings on shell completion
raballew Apr 9, 2026
cb783ba
test: add shell integration tests for bash, zsh, and fish
raballew Apr 9, 2026
a02aa26
fix: address CodeRabbit review findings on shell completion
raballew Apr 9, 2026
0e25f3f
fix: set shell prompt after user profile to prevent override
raballew Apr 10, 2026
4fc31e8
fix: use absolute paths for completion commands in shell init
raballew Apr 13, 2026
2a05ddb
fix: mock shutil.which in absolute path test to work in CI
raballew Apr 13, 2026
efc04ee
fix(tests): relocate get_completion_class mock test to match refactor…
raballew Apr 16, 2026
907612d
fix: source zshrc before compinit to fix macOS compdef errors
raballew Apr 17, 2026
cae8918
fix(shell): source original zshenv when overriding ZDOTDIR
raballew Apr 20, 2026
9664f0c
docs(shell): add docstrings to shell init and launch functions
raballew Apr 20, 2026
43fef15
refactor(shell): move fish init file creation into _launch_fish
raballew Apr 20, 2026
c17cc99
test(shell): add fish launch integration test for init file lifecycle
raballew Apr 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/python-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,11 @@ jobs:
sudo apt-get update
sudo apt-get install -y qemu-system-arm qemu-system-x86

- name: Install libgpiod-dev (Linux)
- name: Install libgpiod-dev and fish (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libgpiod-dev liblgpio-dev
sudo apt-get install -y libgpiod-dev liblgpio-dev fish

- name: Install Renode (Linux)
if: runner.os == 'Linux'
Expand All @@ -100,6 +100,11 @@ jobs:
run: |
brew install renode/tap/renode

- name: Install fish (macOS)
if: runner.os == 'macOS'
run: |
brew install fish

- name: Cache Fedora Cloud images
id: cache-fedora-cloud-images
uses: actions/cache@v5
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from jumpstarter_cli_common.opt import opt_log_level
from jumpstarter_cli_common.version import version

from .completion import completion
from .create import create
from .delete import delete
from .get import get
Expand All @@ -16,6 +17,7 @@ def admin():
"""Jumpstarter Kubernetes cluster admin CLI tool"""


admin.add_command(completion)
admin.add_command(get)
admin.add_command(create)
admin.add_command(delete)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from jumpstarter_cli_common.completion import make_completion_command


def _get_admin():
from jumpstarter_cli_admin import admin

return admin


completion = make_completion_command(_get_admin, "jmp-admin", "_JMP_ADMIN_COMPLETE")
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from click.testing import CliRunner

from . import admin


def test_completion_bash_produces_script_with_jmp_admin():
runner = CliRunner()
result = runner.invoke(admin, ["completion", "bash"])
assert result.exit_code == 0
assert len(result.output) > 0
assert "complete" in result.output.lower()
assert "jmp-admin" in result.output.lower()


def test_completion_zsh_produces_compdef_for_jmp_admin():
runner = CliRunner()
result = runner.invoke(admin, ["completion", "zsh"])
assert result.exit_code == 0
assert len(result.output) > 0
assert "compdef" in result.output.lower()


def test_completion_fish_produces_complete_command_for_jmp_admin():
runner = CliRunner()
result = runner.invoke(admin, ["completion", "fish"])
assert result.exit_code == 0
assert len(result.output) > 0
assert "complete" in result.output.lower()
assert "--command jmp-admin" in result.output.lower()


def test_completion_missing_argument_exits_with_error():
runner = CliRunner()
result = runner.invoke(admin, ["completion"])
assert result.exit_code == 2
assert "Missing argument" in result.output or "bash" in result.output


def test_completion_unsupported_shell_exits_with_error():
runner = CliRunner()
result = runner.invoke(admin, ["completion", "powershell"])
assert result.exit_code == 2
assert "Invalid value" in result.output or "powershell" in result.output
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from typing import Callable

import click
from click.shell_completion import get_completion_class


def make_completion_command(cli_group_factory: Callable[[], click.Command], prog_name: str, complete_var: str):
@click.command("completion")
@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
def completion(shell: str):
"""Generate shell completion script."""
cli_group = cli_group_factory()
comp_cls = get_completion_class(shell)
if comp_cls is None:
raise click.ClickException(f"Unsupported shell: {shell}")
comp = comp_cls(cli_group, {}, prog_name, complete_var)
click.echo(comp.source())
Comment on lines +7 to +17
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recall this one, I also mentioned it in #35 as evaluate the usefulness of jmp complete subcommand.. however upon further examination, the standard way for Click/Typer seems to be:
_JMP_COMPLETE=<zsh/bash/fish>_source jmp which already exists without the implementation. the jmp completion <shell> format seems to be used by the Cobra library in tools written in go such as kubectl, and there are other patterns with other libraries and languages.

so in this case, we're just adding another command exposing the same completion script for which a command already exists, and it's not the native way for python. there is still "some" benefit of familiarity, as we're working closely with k8s/openshift where the cli is built in Cobra so users may be familiar with it more, but that's a thin one.


return completion
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from unittest.mock import patch

import click
from click.testing import CliRunner

from .completion import make_completion_command

PROG_NAME = "testcli"
COMPLETE_VAR = "_TESTCLI_COMPLETE"


def _make_test_group():
@click.group()
def cli():
pass

return cli


def _make_test_cli_with_completion():
@click.group()
def cli():
pass

cli.add_command(make_completion_command(_make_test_group, PROG_NAME, COMPLETE_VAR))
return cli


def test_completion_bash_produces_completion_script():
cli = _make_test_cli_with_completion()
runner = CliRunner()
result = runner.invoke(cli, ["completion", "bash"])
assert result.exit_code == 0
assert "complete" in result.output.lower()
assert PROG_NAME in result.output.lower()


def test_completion_zsh_produces_compdef():
cli = _make_test_cli_with_completion()
runner = CliRunner()
result = runner.invoke(cli, ["completion", "zsh"])
assert result.exit_code == 0
assert "compdef" in result.output.lower()


def test_completion_fish_produces_complete_command():
cli = _make_test_cli_with_completion()
runner = CliRunner()
result = runner.invoke(cli, ["completion", "fish"])
assert result.exit_code == 0
assert "complete" in result.output.lower()
assert f"--command {PROG_NAME}" in result.output.lower()


def test_completion_missing_argument_exits_with_error():
cli = _make_test_cli_with_completion()
runner = CliRunner()
result = runner.invoke(cli, ["completion"])
assert result.exit_code == 2


def test_completion_unsupported_shell_exits_with_error():
cli = _make_test_cli_with_completion()
runner = CliRunner()
result = runner.invoke(cli, ["completion", "powershell"])
assert result.exit_code == 2


def test_completion_raises_when_get_completion_class_returns_none():
with patch("jumpstarter_cli_common.completion.get_completion_class", return_value=None):
cli = _make_test_cli_with_completion()
runner = CliRunner()
result = runner.invoke(cli, ["completion", "bash"])
assert result.exit_code == 1
assert "Unsupported shell" in result.output
17 changes: 6 additions & 11 deletions python/packages/jumpstarter-cli/jumpstarter_cli/completion.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import click
from click.shell_completion import get_completion_class
from jumpstarter_cli_common.completion import make_completion_command


@click.command("completion")
@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
def completion(shell: str):
"""Generate shell completion script."""
def _get_jmp():
from jumpstarter_cli.jmp import jmp

comp_cls = get_completion_class(shell)
if comp_cls is None:
raise click.ClickException(f"Unsupported shell: {shell}")
comp = comp_cls(jmp, {}, "jmp", "_JMP_COMPLETE")
click.echo(comp.source())
return jmp


completion = make_completion_command(_get_jmp, "jmp", "_JMP_COMPLETE")
10 changes: 0 additions & 10 deletions python/packages/jumpstarter-cli/jumpstarter_cli/completion_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from unittest.mock import patch

from click.testing import CliRunner

from .jmp import jmp
Expand Down Expand Up @@ -43,11 +41,3 @@ def test_completion_unsupported_shell():
result = runner.invoke(jmp, ["completion", "powershell"])
assert result.exit_code == 2
assert "Invalid value" in result.output or "powershell" in result.output


def test_completion_raises_when_get_completion_class_returns_none():
with patch("jumpstarter_cli.completion.get_completion_class", return_value=None):
runner = CliRunner()
result = runner.invoke(jmp, ["completion", "bash"])
assert result.exit_code == 1
assert "Unsupported shell" in result.output
32 changes: 32 additions & 0 deletions python/packages/jumpstarter-cli/jumpstarter_cli/j.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import concurrent.futures._base
import os
import sys
from contextlib import ExitStack
from typing import cast

import anyio
import click
from anyio import create_task_group, get_cancelled_exc_class, run, to_thread
from anyio.from_thread import BlockingPortal
from click.exceptions import Exit as ClickExit
from jumpstarter_cli_common.completion import make_completion_command
from jumpstarter_cli_common.exceptions import (
ClickExceptionRed,
async_handle_exceptions,
Expand All @@ -19,6 +22,29 @@
from jumpstarter.common.exceptions import EnvironmentVariableNotSetError
from jumpstarter.utils.env import env_async

j_completion = make_completion_command(lambda: click.Group("j"), "j", "_J_COMPLETE")


_COMPLETION_TIMEOUT_SECONDS = 5


async def _j_shell_complete():
try:
with anyio.fail_after(_COMPLETION_TIMEOUT_SECONDS):
async with BlockingPortal() as portal:
with ExitStack() as stack:
async with env_async(portal, stack) as client:

def _run_completion():
try:
client.cli()(standalone_mode=False)
except SystemExit:
pass

await to_thread.run_sync(_run_completion, abandon_on_cancel=True)
except TimeoutError:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
pass


async def j_async():
@async_handle_exceptions
Expand Down Expand Up @@ -60,6 +86,12 @@ async def cli():

def j():
traceback.install()
if len(sys.argv) >= 2 and sys.argv[1] == "completion":
j_completion(args=sys.argv[2:])
return
if "_J_COMPLETE" in os.environ:
run(_j_shell_complete)
return
run(j_async)


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from unittest.mock import AsyncMock, MagicMock, patch

import anyio
from anyio import run
from click.testing import CliRunner

from .j import _COMPLETION_TIMEOUT_SECONDS, _j_shell_complete, j_completion


def test_j_completion_bash_produces_script():
runner = CliRunner()
result = runner.invoke(j_completion, ["bash"])
assert result.exit_code == 0
assert "complete" in result.output.lower()
assert "_J_COMPLETE" in result.output


def test_j_completion_zsh_produces_compdef():
runner = CliRunner()
result = runner.invoke(j_completion, ["zsh"])
assert result.exit_code == 0
assert "compdef" in result.output.lower()


def test_j_completion_fish_produces_complete_command():
runner = CliRunner()
result = runner.invoke(j_completion, ["fish"])
assert result.exit_code == 0
assert "complete" in result.output.lower()
assert "--command j" in result.output.lower()


def test_j_completion_no_args_exits_with_error():
runner = CliRunner()
result = runner.invoke(j_completion, [])
assert result.exit_code == 2


def test_j_completion_unsupported_shell_exits_with_error():
runner = CliRunner()
result = runner.invoke(j_completion, ["powershell"])
assert result.exit_code == 2


def test_j_shell_complete_handles_system_exit_cleanly():
mock_cli_group = MagicMock()
mock_cli_group.side_effect = SystemExit(0)
mock_client = MagicMock()
mock_client.cli.return_value = mock_cli_group

with patch("jumpstarter_cli.j.env_async") as mock_env:
mock_env.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_env.return_value.__aexit__ = AsyncMock(return_value=False)
run(_j_shell_complete)
mock_client.cli.assert_called_once()
Comment thread
raballew marked this conversation as resolved.
mock_cli_group.assert_called_once()


def test_j_shell_complete_returns_empty_on_timeout():
from contextlib import asynccontextmanager

@asynccontextmanager
async def slow_env(*args, **kwargs):
await anyio.sleep(_COMPLETION_TIMEOUT_SECONDS + 1)
yield MagicMock()

with patch("jumpstarter_cli.j.env_async", slow_env):
result = run(_j_shell_complete)
assert result is None


def test_completion_timeout_is_positive():
assert _COMPLETION_TIMEOUT_SECONDS > 0
Loading
Loading