From a355a7158bc1e73ca263950da6480c7c84b82fe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Sat, 28 Feb 2026 10:16:46 -0800 Subject: [PATCH 1/6] fix(init): show usage menu when flash init called without arguments Previously `flash init` with no arguments silently initialized in the current directory (same as `flash init .`). Now it shows a usage panel with examples and exits, matching expected CLI behavior: - `flash init` prints usage menu - `flash init .` initializes in current directory - `flash init ` creates new project folder --- src/runpod_flash/cli/commands/init.py | 21 ++++++++++++++- tests/unit/cli/commands/test_init.py | 39 ++++++++++++++++++++------- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/runpod_flash/cli/commands/init.py b/src/runpod_flash/cli/commands/init.py index 43ea590e..3505447f 100644 --- a/src/runpod_flash/cli/commands/init.py +++ b/src/runpod_flash/cli/commands/init.py @@ -21,8 +21,27 @@ def init_command( ): """Create new Flash project with Flash Server and GPU workers.""" + # No argument provided — show usage and exit + if project_name is None: + console.print( + Panel( + "[bold]Usage:[/bold]\n\n" + " flash init [bold].[/bold] Initialize in current directory\n" + " flash init [bold][/bold] Create new project in /\n\n" + "[bold]Options:[/bold]\n" + " --force, -f Overwrite existing files\n\n" + "[bold]Examples:[/bold]\n" + " flash init my-project\n" + " flash init .\n" + " flash init my-project --force", + title="flash init", + expand=False, + ) + ) + raise typer.Exit(0) + # Determine target directory and initialization mode - if project_name is None or project_name == ".": + if project_name == ".": # Initialize in current directory project_dir = Path.cwd() is_current_dir = True diff --git a/tests/unit/cli/commands/test_init.py b/tests/unit/cli/commands/test_init.py index e9d0333a..e513d151 100644 --- a/tests/unit/cli/commands/test_init.py +++ b/tests/unit/cli/commands/test_init.py @@ -3,6 +3,8 @@ from unittest.mock import MagicMock, Mock, patch import pytest +import typer +from rich.panel import Panel from runpod_flash.cli.commands.init import init_command @@ -79,18 +81,37 @@ def test_force_flag_skips_confirmation(self, mock_context, tmp_path, monkeypatch mock_context["create_skeleton"].assert_called_once() -class TestInitCommandCurrentDirectory: - """Tests for init command when using current directory.""" +class TestInitCommandNoArgs: + """Tests for init command when called with no arguments.""" - @patch("pathlib.Path.cwd") - def test_init_current_directory_with_none(self, mock_cwd, mock_context, tmp_path): - """Test initialization in current directory with None argument.""" - mock_cwd.return_value = tmp_path + def test_no_args_shows_help_and_exits(self, mock_context): + """flash init with no args should show help and exit.""" + with pytest.raises(typer.Exit) as exc_info: + init_command(None) - init_command(None) + assert exc_info.value.exit_code == 0 - # Verify skeleton was created - mock_context["create_skeleton"].assert_called_once() + def test_no_args_does_not_create_skeleton(self, mock_context): + """flash init with no args should not create project skeleton.""" + with pytest.raises(typer.Exit): + init_command(None) + + mock_context["create_skeleton"].assert_not_called() + + def test_no_args_prints_usage_info(self, mock_context): + """flash init with no args should print usage information.""" + with pytest.raises(typer.Exit): + init_command(None) + + # Verify console.print was called with a Panel containing usage info + mock_context["console"].print.assert_called_once() + panel_arg = mock_context["console"].print.call_args[0][0] + assert isinstance(panel_arg, Panel) + assert "flash init" in panel_arg.title + + +class TestInitCommandCurrentDirectory: + """Tests for init command when using current directory.""" @patch("pathlib.Path.cwd") def test_init_current_directory_with_dot(self, mock_cwd, mock_context, tmp_path): From 3d2e295bd9b8b7cb3a7c9ed3ae06ef1f03326e1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Sat, 28 Feb 2026 13:22:25 -0800 Subject: [PATCH 2/6] fix(init): use Typer built-in help instead of hand-crafted usage panel Replace hand-crafted usage text with ctx.get_help() to prevent drift when CLI options change. Update argument help text to clarify that '.' must be explicitly passed for current-directory init. --- src/runpod_flash/cli/commands/init.py | 19 +---- tests/unit/cli/commands/test_init.py | 102 ++++++++++++++++---------- 2 files changed, 68 insertions(+), 53 deletions(-) diff --git a/src/runpod_flash/cli/commands/init.py b/src/runpod_flash/cli/commands/init.py index 3505447f..8f120e64 100644 --- a/src/runpod_flash/cli/commands/init.py +++ b/src/runpod_flash/cli/commands/init.py @@ -14,8 +14,9 @@ def init_command( + ctx: typer.Context, project_name: Optional[str] = typer.Argument( - None, help="Project name or '.' for current directory" + None, help="Project name, or '.' to initialize in current directory" ), force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing files"), ): @@ -23,21 +24,7 @@ def init_command( # No argument provided — show usage and exit if project_name is None: - console.print( - Panel( - "[bold]Usage:[/bold]\n\n" - " flash init [bold].[/bold] Initialize in current directory\n" - " flash init [bold][/bold] Create new project in /\n\n" - "[bold]Options:[/bold]\n" - " --force, -f Overwrite existing files\n\n" - "[bold]Examples:[/bold]\n" - " flash init my-project\n" - " flash init .\n" - " flash init my-project --force", - title="flash init", - expand=False, - ) - ) + console.print(Panel(ctx.get_help(), title="flash init", expand=False)) raise typer.Exit(0) # Determine target directory and initialization mode diff --git a/tests/unit/cli/commands/test_init.py b/tests/unit/cli/commands/test_init.py index e513d151..7fd0cb7f 100644 --- a/tests/unit/cli/commands/test_init.py +++ b/tests/unit/cli/commands/test_init.py @@ -9,6 +9,14 @@ from runpod_flash.cli.commands.init import init_command +@pytest.fixture +def mock_typer_ctx(): + """Create a mock typer.Context for direct init_command calls.""" + ctx = MagicMock(spec=typer.Context) + ctx.get_help.return_value = "Usage: flash init [OPTIONS] [PROJECT_NAME]" + return ctx + + @pytest.fixture def mock_context(monkeypatch): """Set up mocks for init command testing.""" @@ -46,11 +54,13 @@ def mock_context(monkeypatch): class TestInitCommandNewDirectory: """Tests for init command when creating a new directory.""" - def test_create_new_directory(self, mock_context, tmp_path, monkeypatch): + def test_create_new_directory( + self, mock_typer_ctx, mock_context, tmp_path, monkeypatch + ): """Test creating new project directory.""" monkeypatch.chdir(tmp_path) - init_command("my_project") + init_command(mock_typer_ctx, "my_project") # Verify directory was created assert (tmp_path / "my_project").exists() @@ -61,21 +71,25 @@ def test_create_new_directory(self, mock_context, tmp_path, monkeypatch): # Verify console output mock_context["console"].print.assert_called() - def test_create_nested_directory(self, mock_context, tmp_path, monkeypatch): + def test_create_nested_directory( + self, mock_typer_ctx, mock_context, tmp_path, monkeypatch + ): """Test creating project in nested directory structure.""" monkeypatch.chdir(tmp_path) - init_command("path/to/my_project") + init_command(mock_typer_ctx, "path/to/my_project") # Verify nested directory was created assert (tmp_path / "path/to/my_project").exists() - def test_force_flag_skips_confirmation(self, mock_context, tmp_path, monkeypatch): + def test_force_flag_skips_confirmation( + self, mock_typer_ctx, mock_context, tmp_path, monkeypatch + ): """Test that force flag bypasses conflict prompts.""" monkeypatch.chdir(tmp_path) mock_context["detect_conflicts"].return_value = ["main.py", "requirements.txt"] - init_command("my_project", force=True) + init_command(mock_typer_ctx, "my_project", force=True) # Verify skeleton was created mock_context["create_skeleton"].assert_called_once() @@ -84,24 +98,24 @@ def test_force_flag_skips_confirmation(self, mock_context, tmp_path, monkeypatch class TestInitCommandNoArgs: """Tests for init command when called with no arguments.""" - def test_no_args_shows_help_and_exits(self, mock_context): + def test_no_args_shows_help_and_exits(self, mock_typer_ctx, mock_context): """flash init with no args should show help and exit.""" with pytest.raises(typer.Exit) as exc_info: - init_command(None) + init_command(mock_typer_ctx, None) assert exc_info.value.exit_code == 0 - def test_no_args_does_not_create_skeleton(self, mock_context): + def test_no_args_does_not_create_skeleton(self, mock_typer_ctx, mock_context): """flash init with no args should not create project skeleton.""" with pytest.raises(typer.Exit): - init_command(None) + init_command(mock_typer_ctx, None) mock_context["create_skeleton"].assert_not_called() - def test_no_args_prints_usage_info(self, mock_context): + def test_no_args_prints_usage_info(self, mock_typer_ctx, mock_context): """flash init with no args should print usage information.""" with pytest.raises(typer.Exit): - init_command(None) + init_command(mock_typer_ctx, None) # Verify console.print was called with a Panel containing usage info mock_context["console"].print.assert_called_once() @@ -114,11 +128,13 @@ class TestInitCommandCurrentDirectory: """Tests for init command when using current directory.""" @patch("pathlib.Path.cwd") - def test_init_current_directory_with_dot(self, mock_cwd, mock_context, tmp_path): + def test_init_current_directory_with_dot( + self, mock_cwd, mock_typer_ctx, mock_context, tmp_path + ): """Test initialization in current directory with '.' argument.""" mock_cwd.return_value = tmp_path - init_command(".") + init_command(mock_typer_ctx, ".") # Verify skeleton was created mock_context["create_skeleton"].assert_called_once() @@ -127,21 +143,25 @@ def test_init_current_directory_with_dot(self, mock_cwd, mock_context, tmp_path) class TestInitCommandConflictDetection: """Tests for init command file conflict detection and resolution.""" - def test_no_conflicts_no_prompt(self, mock_context, tmp_path, monkeypatch): + def test_no_conflicts_no_prompt( + self, mock_typer_ctx, mock_context, tmp_path, monkeypatch + ): """Test that prompt is skipped when no conflicts exist.""" monkeypatch.chdir(tmp_path) mock_context["detect_conflicts"].return_value = [] - init_command("my_project") + init_command(mock_typer_ctx, "my_project") # Verify skeleton was created mock_context["create_skeleton"].assert_called_once() - def test_console_called_multiple_times(self, mock_context, tmp_path, monkeypatch): + def test_console_called_multiple_times( + self, mock_typer_ctx, mock_context, tmp_path, monkeypatch + ): """Test that console prints multiple outputs.""" monkeypatch.chdir(tmp_path) - init_command("my_project") + init_command(mock_typer_ctx, "my_project") # Verify console.print was called multiple times assert mock_context["console"].print.call_count > 0 @@ -150,30 +170,36 @@ def test_console_called_multiple_times(self, mock_context, tmp_path, monkeypatch class TestInitCommandOutput: """Tests for init command output messages.""" - def test_panel_title_for_new_directory(self, mock_context, tmp_path, monkeypatch): + def test_panel_title_for_new_directory( + self, mock_typer_ctx, mock_context, tmp_path, monkeypatch + ): """Test that panel output is created for new directory.""" monkeypatch.chdir(tmp_path) - init_command("my_project") + init_command(mock_typer_ctx, "my_project") # Verify console.print was called multiple times assert mock_context["console"].print.call_count > 0 @patch("pathlib.Path.cwd") - def test_panel_title_for_current_directory(self, mock_cwd, mock_context, tmp_path): + def test_panel_title_for_current_directory( + self, mock_cwd, mock_typer_ctx, mock_context, tmp_path + ): """Test that panel output is created for current directory.""" mock_cwd.return_value = tmp_path - init_command(".") + init_command(mock_typer_ctx, ".") # Verify console.print was called assert mock_context["console"].print.call_count > 0 - def test_next_steps_displayed(self, mock_context, tmp_path, monkeypatch): + def test_next_steps_displayed( + self, mock_typer_ctx, mock_context, tmp_path, monkeypatch + ): """Test next steps are displayed.""" monkeypatch.chdir(tmp_path) - init_command("my_project") + init_command(mock_typer_ctx, "my_project") # Verify console.print was called with next steps text assert any( @@ -181,11 +207,13 @@ def test_next_steps_displayed(self, mock_context, tmp_path, monkeypatch): ) @patch("pathlib.Path.cwd") - def test_flash_login_step_displayed(self, mock_cwd, mock_context, tmp_path): - """Test flash login is shown in the next steps table.""" + def test_flash_login_step_displayed( + self, mock_cwd, mock_typer_ctx, mock_context, tmp_path + ): + """Test flash login is shown in the next steps table.""" (fix(init): use Typer built-in help instead of hand-crafted usage panel) mock_cwd.return_value = tmp_path - init_command(".") + init_command(mock_typer_ctx, ".") # The steps table is a Rich Table passed to console.print. # Render it to plain text and check for "flash login". @@ -207,12 +235,12 @@ def test_flash_login_step_displayed(self, mock_cwd, mock_context, tmp_path): assert "flash login" in buf.getvalue() def test_status_message_for_new_directory( - self, mock_context, tmp_path, monkeypatch + self, mock_typer_ctx, mock_context, tmp_path, monkeypatch ): """Test status message while creating new directory.""" monkeypatch.chdir(tmp_path) - init_command("my_project") + init_command(mock_typer_ctx, "my_project") # Check that status was called with appropriate message mock_context["console"].status.assert_called_once() @@ -221,12 +249,12 @@ def test_status_message_for_new_directory( @patch("pathlib.Path.cwd") def test_status_message_for_current_directory( - self, mock_cwd, mock_context, tmp_path + self, mock_cwd, mock_typer_ctx, mock_context, tmp_path ): """Test status message while initializing current directory.""" mock_cwd.return_value = tmp_path - init_command(".") + init_command(mock_typer_ctx, ".") # Check that status was called with initialization message mock_context["console"].status.assert_called_once() @@ -238,23 +266,23 @@ class TestInitCommandProjectNameHandling: """Tests for project name handling.""" def test_special_characters_in_project_name( - self, mock_context, tmp_path, monkeypatch + self, mock_typer_ctx, mock_context, tmp_path, monkeypatch ): """Test project name with special characters.""" monkeypatch.chdir(tmp_path) - init_command("my-project_123") + init_command(mock_typer_ctx, "my-project_123") # Verify directory was created with the exact name assert (tmp_path / "my-project_123").exists() def test_console_called_with_panels_and_tables( - self, mock_context, tmp_path, monkeypatch + self, mock_typer_ctx, mock_context, tmp_path, monkeypatch ): """Test that console prints panels and tables.""" monkeypatch.chdir(tmp_path) - init_command("test_project") + init_command(mock_typer_ctx, "test_project") # Verify console.print was called multiple times assert ( @@ -262,12 +290,12 @@ def test_console_called_with_panels_and_tables( ) # Panel, "Next steps:", Table, API key info def test_directory_created_matches_argument( - self, mock_context, tmp_path, monkeypatch + self, mock_typer_ctx, mock_context, tmp_path, monkeypatch ): """Test that directory created matches the argument.""" monkeypatch.chdir(tmp_path) - init_command("my_awesome_project") + init_command(mock_typer_ctx, "my_awesome_project") # Verify directory was created with exact name assert (tmp_path / "my_awesome_project").exists() From d1a240ca9a2a1cb69e95aeebf7ef9ccff633a30c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Mon, 16 Mar 2026 16:18:16 -0700 Subject: [PATCH 3/6] fix(test): remove stray commit message from test docstring Syntax error from commit message text accidentally appended to a docstring in test_init.py, breaking ruff format checks. --- tests/unit/cli/commands/test_init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/cli/commands/test_init.py b/tests/unit/cli/commands/test_init.py index 7fd0cb7f..bdb87d6a 100644 --- a/tests/unit/cli/commands/test_init.py +++ b/tests/unit/cli/commands/test_init.py @@ -210,7 +210,7 @@ def test_next_steps_displayed( def test_flash_login_step_displayed( self, mock_cwd, mock_typer_ctx, mock_context, tmp_path ): - """Test flash login is shown in the next steps table.""" (fix(init): use Typer built-in help instead of hand-crafted usage panel) + """Test flash login is shown in the next steps table.""" mock_cwd.return_value = tmp_path init_command(mock_typer_ctx, ".") From 44f987f93c0e7b6e965307656a66b16ddbb20edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Wed, 18 Mar 2026 14:02:30 -0700 Subject: [PATCH 4/6] fix(init): use plain help text instead of empty Panel wrapper KAJdev review feedback: Panel(ctx.get_help()) renders an empty panel. Use console.print(ctx.get_help()) directly instead. --- src/runpod_flash/cli/commands/init.py | 2 +- tests/unit/cli/commands/test_init.py | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/runpod_flash/cli/commands/init.py b/src/runpod_flash/cli/commands/init.py index 8f120e64..4658563e 100644 --- a/src/runpod_flash/cli/commands/init.py +++ b/src/runpod_flash/cli/commands/init.py @@ -24,7 +24,7 @@ def init_command( # No argument provided — show usage and exit if project_name is None: - console.print(Panel(ctx.get_help(), title="flash init", expand=False)) + console.print(ctx.get_help()) raise typer.Exit(0) # Determine target directory and initialization mode diff --git a/tests/unit/cli/commands/test_init.py b/tests/unit/cli/commands/test_init.py index bdb87d6a..dcf367a2 100644 --- a/tests/unit/cli/commands/test_init.py +++ b/tests/unit/cli/commands/test_init.py @@ -4,7 +4,6 @@ import pytest import typer -from rich.panel import Panel from runpod_flash.cli.commands.init import init_command @@ -117,11 +116,10 @@ def test_no_args_prints_usage_info(self, mock_typer_ctx, mock_context): with pytest.raises(typer.Exit): init_command(mock_typer_ctx, None) - # Verify console.print was called with a Panel containing usage info + # Verify console.print was called with help text mock_context["console"].print.assert_called_once() - panel_arg = mock_context["console"].print.call_args[0][0] - assert isinstance(panel_arg, Panel) - assert "flash init" in panel_arg.title + help_arg = mock_context["console"].print.call_args[0][0] + assert "flash init" in help_arg class TestInitCommandCurrentDirectory: From 02c9798811152b970c1d43e37ad8b2155a53304f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Wed, 18 Mar 2026 14:22:30 -0700 Subject: [PATCH 5/6] fix(init): disable Rich markup parsing on help text output Typer help strings contain [OPTIONS] which Rich interprets as markup tags, causing MarkupError or mangled output. Pass markup=False and highlight=False to console.print. Test updated to verify this. --- src/runpod_flash/cli/commands/init.py | 2 +- tests/unit/cli/commands/test_init.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/runpod_flash/cli/commands/init.py b/src/runpod_flash/cli/commands/init.py index 4658563e..9ef8e3cb 100644 --- a/src/runpod_flash/cli/commands/init.py +++ b/src/runpod_flash/cli/commands/init.py @@ -24,7 +24,7 @@ def init_command( # No argument provided — show usage and exit if project_name is None: - console.print(ctx.get_help()) + console.print(ctx.get_help(), markup=False, highlight=False) raise typer.Exit(0) # Determine target directory and initialization mode diff --git a/tests/unit/cli/commands/test_init.py b/tests/unit/cli/commands/test_init.py index dcf367a2..4fe389ca 100644 --- a/tests/unit/cli/commands/test_init.py +++ b/tests/unit/cli/commands/test_init.py @@ -116,10 +116,13 @@ def test_no_args_prints_usage_info(self, mock_typer_ctx, mock_context): with pytest.raises(typer.Exit): init_command(mock_typer_ctx, None) - # Verify console.print was called with help text + # Verify console.print was called with help text and markup disabled mock_context["console"].print.assert_called_once() help_arg = mock_context["console"].print.call_args[0][0] assert "flash init" in help_arg + kwargs = mock_context["console"].print.call_args[1] + assert kwargs.get("markup") is False + assert kwargs.get("highlight") is False class TestInitCommandCurrentDirectory: From 715c2915ff731339e25d2e4c7a344e9954696b6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Wed, 18 Mar 2026 14:39:08 -0700 Subject: [PATCH 6/6] fix(test): add CliRunner integration test and assert ctx.get_help() call Address Henrik review feedback: - Add CLI-level test via CliRunner to verify Typer context injection - Assert ctx.get_help() was called in no-args test --- tests/unit/cli/commands/test_init.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/unit/cli/commands/test_init.py b/tests/unit/cli/commands/test_init.py index 4fe389ca..794cb566 100644 --- a/tests/unit/cli/commands/test_init.py +++ b/tests/unit/cli/commands/test_init.py @@ -116,7 +116,8 @@ def test_no_args_prints_usage_info(self, mock_typer_ctx, mock_context): with pytest.raises(typer.Exit): init_command(mock_typer_ctx, None) - # Verify console.print was called with help text and markup disabled + # Verify ctx.get_help() was called and passed to console.print + mock_typer_ctx.get_help.assert_called_once() mock_context["console"].print.assert_called_once() help_arg = mock_context["console"].print.call_args[0][0] assert "flash init" in help_arg @@ -125,6 +126,20 @@ def test_no_args_prints_usage_info(self, mock_typer_ctx, mock_context): assert kwargs.get("highlight") is False +class TestInitCommandCliRunner: + """CLI-level test to verify Typer context injection works end-to-end.""" + + def test_no_args_via_cli(self): + """flash init with no args should show help and exit 0 via CLI.""" + from typer.testing import CliRunner + + from runpod_flash.cli.main import app + + result = CliRunner().invoke(app, ["init"]) + assert result.exit_code == 0 + assert "flash init" in result.output.lower() + + class TestInitCommandCurrentDirectory: """Tests for init command when using current directory."""