diff --git a/.gitignore b/.gitignore index 11cc59847..1d178b2a2 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ AGENTS.md server.log CLAUDE.md .claude/ + +# Auto-generated build info +llama_stack/cli/build_info.py diff --git a/llama_stack/cli/stack/stack.py b/llama_stack/cli/stack/stack.py index 3aff78e23..b1282efdc 100644 --- a/llama_stack/cli/stack/stack.py +++ b/llama_stack/cli/stack/stack.py @@ -10,6 +10,7 @@ from importlib.metadata import version from llama_stack.cli.stack.list_stacks import StackListBuilds from llama_stack.cli.stack.utils import print_subcommand_description from llama_stack.cli.subcommand import Subcommand +from llama_stack.cli.version import VersionCommand from .build import StackBuild from .list_apis import StackListApis @@ -45,4 +46,5 @@ class StackParser(Subcommand): StackRun.create(subparsers) StackRemove.create(subparsers) StackListBuilds.create(subparsers) + VersionCommand.create(subparsers) print_subcommand_description(self.parser, subparsers) diff --git a/llama_stack/cli/version.py b/llama_stack/cli/version.py new file mode 100755 index 000000000..24ccc1a29 --- /dev/null +++ b/llama_stack/cli/version.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 + +# 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. + +import argparse +import sys +from importlib.metadata import PackageNotFoundError, version +from typing import Any + +from rich.console import Console +from rich.table import Table + +from llama_stack.cli.subcommand import Subcommand + +# Import build info at module level for testing +try: + from .build_info import BUILD_INFO +except ImportError: + BUILD_INFO = None # type: ignore + + +def print_simple_table(rows, width=80): + """Print a simple table with fixed width""" + + table = Table(show_header=True, width=width) + table.add_column("Property", width=30) + table.add_column("Value", width=46) + + for row in rows: + table.add_row(*row) + + Console(width=width).print(table) + + +class VersionCommand(Subcommand): + """Display version information for Llama Stack CLI and server""" + + def __init__(self, subparsers: argparse._SubParsersAction): + super().__init__() + self.parser = subparsers.add_parser( + "version", + prog="llama version", + description="Display version information for Llama Stack CLI and server", + formatter_class=argparse.RawTextHelpFormatter, + ) + self._add_arguments() + self.parser.set_defaults(func=self._run_version_command) + + def _add_arguments(self): + self.parser.add_argument( + "-o", + "--output", + choices=["table", "json"], + default="table", + help="Output format (table, json)", + ) + self.parser.add_argument( + "-b", + "--build-info", + action="store_true", + help="Include build information (git commit, date, branch, tag)", + ) + self.parser.add_argument( + "-d", + "--dependencies", + action="store_true", + help="Include dependency versions information", + ) + self.parser.add_argument( + "-a", + "--all", + action="store_true", + help="Display all information (build info + dependencies)", + ) + + def _get_package_version(self, package_name: str) -> str: + """Get version of a package, return 'unknown' if not found""" + try: + return version(package_name) + except PackageNotFoundError: + return "unknown" + + def _get_build_info(self) -> dict: + """Get build information from build_info.py""" + build_info = { + "commit_hash": "unknown", + "commit_date": "unknown", + "branch": "unknown", + "tag": "unknown", + "build_timestamp": "unknown", + } + + try: + if BUILD_INFO is not None: + build_info.update( + { + "commit_hash": BUILD_INFO.get("git_commit", "unknown"), + "commit_date": BUILD_INFO.get("git_commit_date", "unknown"), + "branch": BUILD_INFO.get("git_branch", "unknown"), + "tag": BUILD_INFO.get("git_tag", "unknown"), + "build_timestamp": BUILD_INFO.get("build_timestamp", "unknown"), + } + ) + except (ImportError, AttributeError, TypeError): + # build_info.py not available or BUILD_INFO raises exception, use default values + pass + + return build_info + + def _get_installed_packages(self) -> list[tuple[str, str]]: + """Get installed packages as (name, version) tuples using pipdeptree or fallback to importlib.metadata""" + # Try pipdeptree first (cleanest approach) + try: + import pipdeptree # type: ignore + + tree = pipdeptree.get_installed_distributions() + return [(pkg.project_name.lower(), pkg.version) for pkg in tree] + except ImportError: + pass + + # Fallback to importlib.metadata + try: + from importlib.metadata import distributions + + return [(dist.metadata["Name"].lower(), dist.version) for dist in distributions()] + except ImportError: + return [] + + def _get_dependencies(self) -> dict[str, str]: + """Get versions of installed dependencies""" + packages = self._get_installed_packages() + return dict(packages) + + def _get_project_dependencies(self) -> list[str]: + """Get project dependencies""" + packages = self._get_installed_packages() + return [name for name, version in packages] + + def _run_version_command(self, args: argparse.Namespace) -> None: + """Execute the version command""" + import json + + llama_stack_version = self._get_package_version("llama-stack") + + # Default behavior: just show version like llama stack --version + if not any([args.build_info, args.dependencies, args.all]): + if args.output == "json": + print(json.dumps({"llama_stack_version": llama_stack_version})) + else: + print(llama_stack_version) + return + + # Extended behavior: show additional information + llama_stack_client_version = self._get_package_version("llama-stack-client") + python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + + version_info: dict[str, Any] = { + "llama_stack_version": llama_stack_version, + "llama_stack_client_version": llama_stack_client_version, + "python_version": python_version, + } + + # Add build info if requested + if args.build_info or args.all: + build_info = self._get_build_info() + version_info.update( + { + "git_commit": build_info["commit_hash"], + "git_commit_date": build_info["commit_date"], + "git_branch": build_info["branch"], + "git_tag": build_info["tag"], + "build_timestamp": build_info["build_timestamp"], + } + ) + + # Add dependencies if requested + if args.dependencies or args.all: + version_info["dependencies"] = self._get_dependencies() + + if args.output == "json": + print(json.dumps(version_info)) + else: + # Table format + print("Llama Stack Version Information") + print("=" * 50) + + # Build simple rows + rows = [ + ["Llama Stack", version_info["llama_stack_version"]], + ["Llama Stack Client", version_info["llama_stack_client_version"]], + ["Python", version_info["python_version"]], + ] + + # Add build info if requested + if args.build_info or args.all: + rows.extend( + [ + ["", ""], # separator + ["Git Commit", version_info["git_commit"]], + ["Commit Date", version_info["git_commit_date"]], + ["Git Branch", version_info["git_branch"]], + ["Git Tag", version_info["git_tag"]], + ["Build Timestamp", version_info["build_timestamp"]], + ] + ) + + # Add dependencies if requested + if args.dependencies or args.all: + deps = self._get_dependencies() + rows.extend( + [ + ["", ""], # separator + ["Dependencies", f"{len(deps)} packages"], + ] + ) + for dep, ver in sorted(deps.items()): + rows.append([f" {dep}", ver]) + + # Print simple table + print_simple_table(rows) diff --git a/scripts/generate_build_info.py b/scripts/generate_build_info.py new file mode 100755 index 000000000..38483db40 --- /dev/null +++ b/scripts/generate_build_info.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 + +# 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. + +# This file is auto-generated during build time +# DO NOT EDIT MANUALLY + +import subprocess +import sys +from datetime import UTC, datetime +from pathlib import Path + + +def get_git_info(): + """Get git information for build""" + git_info = { + "git_commit": "unknown", + "git_commit_date": "unknown", + "git_branch": "unknown", + "git_tag": "unknown", + } + + try: + # Get current directory - assume script is in scripts/ directory + repo_root = Path(__file__).parent.parent + + # Get commit hash + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo_root, + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + git_info["git_commit"] = result.stdout.strip()[:12] # Short hash + + # Get commit date + result = subprocess.run( + ["git", "log", "-1", "--format=%ci"], + cwd=repo_root, + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + git_info["git_commit_date"] = result.stdout.strip() + + # Get current branch + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=repo_root, + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + branch = result.stdout.strip() + # If we're in detached HEAD state (common in CI), try to get branch from env + if branch == "HEAD": + # Try common CI environment variables + import os + + branch = ( + os.getenv("GITHUB_REF_NAME") + or os.getenv("CI_COMMIT_REF_NAME") # GitLab + or os.getenv("BUILDKITE_BRANCH") + or os.getenv("TRAVIS_BRANCH") + or "HEAD" + ) + git_info["git_branch"] = branch + + # Get latest tag + result = subprocess.run( + ["git", "describe", "--tags", "--abbrev=0"], + cwd=repo_root, + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + git_info["git_tag"] = result.stdout.strip() + else: + # If no tags, try to get the closest tag with distance + result = subprocess.run( + ["git", "describe", "--tags", "--always"], + cwd=repo_root, + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + git_info["git_tag"] = result.stdout.strip() + + except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError) as e: + print(f"Warning: Could not get git information: {e}", file=sys.stderr) + + return git_info + + +def generate_build_info_file(): + """Generate the build_info.py file with current git information""" + git_info = get_git_info() + + # Add build timestamp + build_timestamp = datetime.now(UTC).isoformat() + + # Get the target file path + script_dir = Path(__file__).parent + target_file = script_dir.parent / "llama_stack" / "cli" / "build_info.py" + + # Generate the content + content = f"""#!/usr/bin/env python3 + +# 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. + +# This file is auto-generated during build time +# DO NOT EDIT MANUALLY + +BUILD_INFO = {{ + "git_commit": "{git_info["git_commit"]}", + "git_commit_date": "{git_info["git_commit_date"]}", + "git_branch": "{git_info["git_branch"]}", + "git_tag": "{git_info["git_tag"]}", + "build_timestamp": "{build_timestamp}", +}} +""" + + # Write the file + try: + target_file.write_text(content) + print(f"Generated build info file: {target_file}") + print(f"Git commit: {git_info['git_commit']}") + print(f"Git branch: {git_info['git_branch']}") + print(f"Git tag: {git_info['git_tag']}") + print(f"Build timestamp: {build_timestamp}") + except Exception as e: + print(f"Error writing build info file: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + generate_build_info_file() diff --git a/setup.py b/setup.py new file mode 100755 index 000000000..b8961a235 --- /dev/null +++ b/setup.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 + +# 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. + +import subprocess +import sys +from pathlib import Path + +from setuptools import setup +from setuptools.command.build_py import build_py + + +class BuildWithBuildInfo(build_py): + """Custom build command that generates build info before building""" + + def run(self): + # Generate build info before building + self.generate_build_info() + # Run the standard build + super().run() + + def generate_build_info(self): + """Generate build_info.py with current git information""" + script_path = Path(__file__).parent / "scripts" / "generate_build_info.py" + + try: + # Run the build info generation script + result = subprocess.run( + [sys.executable, str(script_path)], + check=True, + capture_output=True, + text=True, + ) + print("Build info generation completed successfully") + if result.stdout: + print(result.stdout) + except subprocess.CalledProcessError as e: + print(f"Warning: Failed to generate build info: {e}") + if e.stderr: + print(f"Error output: {e.stderr}") + # Don't fail the build, just continue with default values + + +if __name__ == "__main__": + setup( + cmdclass={ + "build_py": BuildWithBuildInfo, + }, + ) diff --git a/tests/unit/cli/test_version.py b/tests/unit/cli/test_version.py new file mode 100644 index 000000000..a79a8acc4 --- /dev/null +++ b/tests/unit/cli/test_version.py @@ -0,0 +1,89 @@ +# 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. + +import argparse +import json +from unittest.mock import patch + +import pytest + +from llama_stack.cli.version import VersionCommand + + +class TestVersionCommand: + """Test suite for the VersionCommand class""" + + @pytest.fixture + def version_command(self): + """Create a VersionCommand instance for testing""" + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers() + return VersionCommand(subparsers) + + def test_version_command_basic_functionality(self, version_command): + """Test basic version command functionality""" + # Test package version retrieval + with patch("llama_stack.cli.version.version") as mock_version: + mock_version.return_value = "0.2.12" + assert version_command._get_package_version("llama-stack") == "0.2.12" + + # Test missing package + from importlib.metadata import PackageNotFoundError + + mock_version.side_effect = PackageNotFoundError() + assert version_command._get_package_version("missing-package") == "unknown" + + # Test build info with mocked BUILD_INFO + mock_build_info = { + "git_commit": "abc123", + "git_commit_date": "2025-01-15", + "git_branch": "main", + "git_tag": "v0.2.12", + "build_timestamp": "2025-01-15T18:30:00+00:00", + } + with patch("llama_stack.cli.version.BUILD_INFO", mock_build_info): + result = version_command._get_build_info() + assert result["commit_hash"] == "abc123" + assert result["branch"] == "main" + + # Test default JSON output (should only show version) + args_default = argparse.Namespace(output="json", build_info=False, dependencies=False, all=False) + with ( + patch.object(version_command, "_get_package_version") as mock_get_version, + patch("builtins.print") as mock_print, + ): + mock_get_version.return_value = "0.2.12" + + version_command._run_version_command(args_default) + + printed_output = mock_print.call_args[0][0] + json_output = json.loads(printed_output) + assert json_output["llama_stack_version"] == "0.2.12" + # Should not include build info in default output + assert "git_commit" not in json_output + + # Test JSON output with build info + args_with_build_info = argparse.Namespace(output="json", build_info=True, dependencies=False, all=False) + with ( + patch.object(version_command, "_get_package_version") as mock_get_version, + patch.object(version_command, "_get_build_info") as mock_get_build_info, + patch("builtins.print") as mock_print, + ): + mock_get_version.return_value = "0.2.12" + mock_get_build_info.return_value = { + "commit_hash": "abc123", + "commit_date": "2025-01-15", + "branch": "main", + "tag": "v0.2.12", + "build_timestamp": "2025-01-15T18:30:00+00:00", + } + + version_command._run_version_command(args_with_build_info) + + printed_output = mock_print.call_args[0][0] + json_output = json.loads(printed_output) + assert json_output["llama_stack_version"] == "0.2.12" + assert json_output["git_commit"] == "abc123"