diff --git a/src/llama_stack/cli/stack/list_stacks.py b/src/llama_stack/cli/stack/list_stacks.py index 2ea0fdeea..07e4d825c 100644 --- a/src/llama_stack/cli/stack/list_stacks.py +++ b/src/llama_stack/cli/stack/list_stacks.py @@ -9,48 +9,69 @@ from pathlib import Path from llama_stack.cli.subcommand import Subcommand from llama_stack.cli.table import print_table +from llama_stack.core.utils.config_dirs import DISTRIBS_BASE_DIR class StackListBuilds(Subcommand): - """List built stacks in .llama/distributions directory""" + """List available distributions (both built-in and built)""" def __init__(self, subparsers: argparse._SubParsersAction): super().__init__() self.parser = subparsers.add_parser( "list", prog="llama stack list", - description="list the build stacks", + description="list available distributions", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) self._add_arguments() self.parser.set_defaults(func=self._list_stack_command) - def _get_distribution_dirs(self) -> dict[str, Path]: - """Return a dictionary of distribution names and their paths""" - distributions = {} - dist_dir = Path.home() / ".llama" / "distributions" + def _get_distribution_dirs(self) -> dict[str, tuple[Path, str]]: + """Return a dictionary of distribution names and their paths with source type + + Returns: + dict mapping distro name to (path, source_type) where source_type is 'built-in' or 'built' + """ + distributions = {} + + # Get built-in distributions from source code + distro_dir = Path(__file__).parent.parent.parent / "distributions" + if distro_dir.exists(): + for stack_dir in distro_dir.iterdir(): + if stack_dir.is_dir() and not stack_dir.name.startswith(".") and not stack_dir.name.startswith("__"): + distributions[stack_dir.name] = (stack_dir, "built-in") + + # Get built/run distributions from ~/.llama/distributions + # These override built-in ones if they have the same name + if DISTRIBS_BASE_DIR.exists(): + for stack_dir in DISTRIBS_BASE_DIR.iterdir(): + if stack_dir.is_dir() and not stack_dir.name.startswith("."): + # Clean up the name (remove llamastack- prefix if present) + name = stack_dir.name.replace("llamastack-", "") + distributions[name] = (stack_dir, "built") - if dist_dir.exists(): - for stack_dir in dist_dir.iterdir(): - if stack_dir.is_dir(): - distributions[stack_dir.name] = stack_dir return distributions def _list_stack_command(self, args: argparse.Namespace) -> None: distributions = self._get_distribution_dirs() if not distributions: - print("No stacks found in ~/.llama/distributions") + print("No distributions found") return - headers = ["Stack Name", "Path"] - headers.extend(["Build Config", "Run Config"]) + headers = ["Stack Name", "Source", "Path", "Build Config", "Run Config"] rows = [] - for name, path in distributions.items(): - row = [name, str(path)] + for name, (path, source_type) in sorted(distributions.items()): + row = [name, source_type, str(path)] # Check for build and run config files - build_config = "Yes" if (path / f"{name}-build.yaml").exists() else "No" - run_config = "Yes" if (path / f"{name}-run.yaml").exists() else "No" + # For built-in distributions, configs are named build.yaml and run.yaml + # For built distributions, configs are named {name}-build.yaml and {name}-run.yaml + if source_type == "built-in": + build_config = "Yes" if (path / "build.yaml").exists() else "No" + run_config = "Yes" if (path / "run.yaml").exists() else "No" + else: + build_config = "Yes" if (path / f"{name}-build.yaml").exists() else "No" + run_config = "Yes" if (path / f"{name}-run.yaml").exists() else "No" row.extend([build_config, run_config]) rows.append(row) print_table(rows, headers, separate_rows=True) diff --git a/tests/unit/distribution/test_stack_list.py b/tests/unit/distribution/test_stack_list.py new file mode 100644 index 000000000..fa4fe64f9 --- /dev/null +++ b/tests/unit/distribution/test_stack_list.py @@ -0,0 +1,229 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the terms described in the LICENSE file in +# the root directory of this source tree. + +""" +Tests for the llama stack list command. + +These tests verify the fix for issue #3922 where `llama stack list` only showed +distributions after they were run. +""" + +import argparse +from unittest.mock import MagicMock, patch + +import pytest + +from llama_stack.cli.stack.list_stacks import StackListBuilds + + +@pytest.fixture +def list_stacks_command(): + """Create a StackListBuilds instance for testing.""" + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers() + return StackListBuilds(subparsers) + + +@pytest.fixture +def mock_distribs_base_dir(tmp_path): + """Create a mock DISTRIBS_BASE_DIR with some built distributions.""" + built_dir = tmp_path / "distributions" + built_dir.mkdir(parents=True, exist_ok=True) + + # Create a built distribution + starter_built = built_dir / "starter" + starter_built.mkdir() + (starter_built / "starter-build.yaml").write_text("# build config") + (starter_built / "starter-run.yaml").write_text("# run config") + + return built_dir + + +@pytest.fixture +def mock_distro_dir(tmp_path): + """Create a mock distributions directory with built-in distributions.""" + distro_dir = tmp_path / "src" / "llama_stack" / "distributions" + distro_dir.mkdir(parents=True, exist_ok=True) + + # Create some built-in distributions + for distro_name in ["starter", "nvidia", "dell"]: + distro_path = distro_dir / distro_name + distro_path.mkdir() + (distro_path / "build.yaml").write_text("# build config") + (distro_path / "run.yaml").write_text("# run config") + + return distro_dir + + +def create_path_mock(builtin_dist_dir): + """Create a properly mocked Path object that returns builtin_dist_dir for the distributions path.""" + mock_parent_parent_parent = MagicMock() + mock_parent_parent_parent.__truediv__ = lambda self, other: builtin_dist_dir if other == "distributions" else MagicMock() + + mock_path = MagicMock() + mock_path.parent.parent.parent = mock_parent_parent_parent + + return mock_path + + +class TestStackList: + """Test suite for llama stack list command.""" + + def test_builtin_distros_shown_without_running(self, list_stacks_command, mock_distro_dir, tmp_path): + """Test that built-in distributions are shown even before running them. + + This verifies the fix for issue #3922 where `llama stack list` only showed + distributions after they were run. + """ + mock_path = create_path_mock(mock_distro_dir) + + # Mock DISTRIBS_BASE_DIR to be a non-existent directory (no built distributions) + with patch("llama_stack.cli.stack.list_stacks.DISTRIBS_BASE_DIR", tmp_path / "nonexistent"): + with patch("llama_stack.cli.stack.list_stacks.Path") as mock_path_class: + mock_path_class.return_value = mock_path + + distributions = list_stacks_command._get_distribution_dirs() + + # Verify built-in distributions are found + assert len(distributions) > 0, "Should find built-in distributions" + assert all(source_type == "built-in" for _, source_type in distributions.values()), "All should be built-in" + + # Check specific distributions we created + assert "starter" in distributions + assert "nvidia" in distributions + assert "dell" in distributions + + def test_builtin_and_built_distros_shown_together(self, list_stacks_command, mock_distro_dir, mock_distribs_base_dir): + """Test that both built-in and built distributions are shown together.""" + mock_path = create_path_mock(mock_distro_dir) + + with patch("llama_stack.cli.stack.list_stacks.DISTRIBS_BASE_DIR", mock_distribs_base_dir): + with patch("llama_stack.cli.stack.list_stacks.Path") as mock_path_class: + mock_path_class.return_value = mock_path + + distributions = list_stacks_command._get_distribution_dirs() + + # Should have built-in distributions + builtin_count = sum(1 for _, source_type in distributions.values() if source_type == "built-in") + # Should have built distributions + built_count = sum(1 for _, source_type in distributions.values() if source_type == "built") + + assert builtin_count > 0, "Should have built-in distributions" + assert built_count > 0, "Should have built distributions" + + def test_built_distribution_overrides_builtin(self, list_stacks_command, mock_distro_dir, mock_distribs_base_dir): + """Test that built distributions override built-in ones with the same name.""" + mock_path = create_path_mock(mock_distro_dir) + + with patch("llama_stack.cli.stack.list_stacks.DISTRIBS_BASE_DIR", mock_distribs_base_dir): + with patch("llama_stack.cli.stack.list_stacks.Path") as mock_path_class: + mock_path_class.return_value = mock_path + + distributions = list_stacks_command._get_distribution_dirs() + + # "starter" should exist and be marked as "built" (not "built-in") + # because the built version overrides the built-in one + assert "starter" in distributions + _, source_type = distributions["starter"] + assert source_type == "built", "Built distribution should override built-in" + + def test_empty_distributions(self, list_stacks_command, tmp_path): + """Test behavior when no distributions exist.""" + nonexistent = tmp_path / "nonexistent" + mock_path = create_path_mock(nonexistent) + + with patch("llama_stack.cli.stack.list_stacks.DISTRIBS_BASE_DIR", nonexistent): + with patch("llama_stack.cli.stack.list_stacks.Path") as mock_path_class: + mock_path_class.return_value = mock_path + + distributions = list_stacks_command._get_distribution_dirs() + + assert len(distributions) == 0, "Should return empty dict when no distributions exist" + + def test_config_files_detection_builtin(self, list_stacks_command, mock_distro_dir, tmp_path): + """Test that config files are correctly detected for built-in distributions.""" + mock_path = create_path_mock(mock_distro_dir) + + with patch("llama_stack.cli.stack.list_stacks.DISTRIBS_BASE_DIR", tmp_path / "nonexistent"): + with patch("llama_stack.cli.stack.list_stacks.Path") as mock_path_class: + mock_path_class.return_value = mock_path + + distributions = list_stacks_command._get_distribution_dirs() + + # Check that starter has both config files + if "starter" in distributions: + path, source_type = distributions["starter"] + if source_type == "built-in": + assert (path / "build.yaml").exists() + assert (path / "run.yaml").exists() + + def test_config_files_detection_built(self, list_stacks_command, tmp_path): + """Test that config files are correctly detected for built distributions.""" + # Create a built distribution + built_dir = tmp_path / "distributions" + built_dir.mkdir(parents=True) + test_distro = built_dir / "test-distro" + test_distro.mkdir() + (test_distro / "test-distro-build.yaml").write_text("# build") + (test_distro / "test-distro-run.yaml").write_text("# run") + + nonexistent = tmp_path / "nonexistent" + mock_path = create_path_mock(nonexistent) + + with patch("llama_stack.cli.stack.list_stacks.DISTRIBS_BASE_DIR", built_dir): + with patch("llama_stack.cli.stack.list_stacks.Path") as mock_path_class: + mock_path_class.return_value = mock_path + + distributions = list_stacks_command._get_distribution_dirs() + + assert "test-distro" in distributions + path, source_type = distributions["test-distro"] + assert source_type == "built" + assert (path / "test-distro-build.yaml").exists() + assert (path / "test-distro-run.yaml").exists() + + def test_llamastack_prefix_stripped(self, list_stacks_command, tmp_path): + """Test that llamastack- prefix is stripped from built distribution names.""" + # Create a built distribution with llamastack- prefix + built_dir = tmp_path / "distributions" + built_dir.mkdir(parents=True) + distro_with_prefix = built_dir / "llamastack-mystack" + distro_with_prefix.mkdir() + + nonexistent = tmp_path / "nonexistent" + mock_path = create_path_mock(nonexistent) + + with patch("llama_stack.cli.stack.list_stacks.DISTRIBS_BASE_DIR", built_dir): + with patch("llama_stack.cli.stack.list_stacks.Path") as mock_path_class: + mock_path_class.return_value = mock_path + + distributions = list_stacks_command._get_distribution_dirs() + + # Should be listed as "mystack" not "llamastack-mystack" + assert "mystack" in distributions + assert "llamastack-mystack" not in distributions + + def test_hidden_directories_ignored(self, list_stacks_command, mock_distro_dir, tmp_path): + """Test that hidden directories (starting with .) are ignored.""" + # Add a hidden directory + hidden_dir = mock_distro_dir / ".hidden" + hidden_dir.mkdir() + (hidden_dir / "build.yaml").write_text("# build") + + # Add a __pycache__ directory + pycache_dir = mock_distro_dir / "__pycache__" + pycache_dir.mkdir() + + mock_path = create_path_mock(mock_distro_dir) + + with patch("llama_stack.cli.stack.list_stacks.DISTRIBS_BASE_DIR", tmp_path / "nonexistent"): + with patch("llama_stack.cli.stack.list_stacks.Path") as mock_path_class: + mock_path_class.return_value = mock_path + + distributions = list_stacks_command._get_distribution_dirs() + + assert ".hidden" not in distributions + assert "__pycache__" not in distributions