From 1681e29fa8a32ae027503b2c9b108a35fb721cf8 Mon Sep 17 00:00:00 2001 From: Musa Date: Tue, 6 Jan 2026 11:05:55 -0800 Subject: [PATCH] add cli ux --- cli/planoai/main.py | 450 +++++++++++++++++++++++++++++++-- cli/pyproject.toml | 2 + cli/test/test_validate.py | 218 ++++++++++++++++ cli/test/test_version_check.py | 163 ++++++++++++ cli/uv.lock | 53 ++++ 5 files changed, 865 insertions(+), 21 deletions(-) create mode 100644 cli/test/test_validate.py create mode 100644 cli/test/test_version_check.py diff --git a/cli/planoai/main.py b/cli/planoai/main.py index a2737883..284e57db 100644 --- a/cli/planoai/main.py +++ b/cli/planoai/main.py @@ -1,4 +1,4 @@ -import click +import rich_click as click import os import sys import subprocess @@ -6,6 +6,74 @@ import multiprocessing import importlib.metadata import json from planoai import targets + +# Brand color - Plano purple +PLANO_COLOR = "#969FF4" + +# Configure rich-click styling +click.rich_click.USE_RICH_MARKUP = True +click.rich_click.USE_MARKDOWN = False +click.rich_click.SHOW_ARGUMENTS = True +click.rich_click.GROUP_ARGUMENTS_OPTIONS = True +click.rich_click.STYLE_ERRORS_SUGGESTION = "dim italic" +click.rich_click.ERRORS_SUGGESTION = ( + "Try running the '--help' flag for more information." +) +click.rich_click.ERRORS_EPILOGUE = "" + +# Custom colors matching Plano brand +click.rich_click.STYLE_OPTION = f"dim {PLANO_COLOR}" +click.rich_click.STYLE_ARGUMENT = f"dim {PLANO_COLOR}" +click.rich_click.STYLE_COMMAND = f"bold {PLANO_COLOR}" +click.rich_click.STYLE_SWITCH = "bold green" +click.rich_click.STYLE_METAVAR = "bold yellow" +click.rich_click.STYLE_USAGE = "bold" +click.rich_click.STYLE_USAGE_COMMAND = f"bold dim {PLANO_COLOR}" +click.rich_click.STYLE_HELPTEXT_FIRST_LINE = f"white italic" +click.rich_click.STYLE_HELPTEXT = "" +click.rich_click.STYLE_HEADER_TEXT = "bold" +click.rich_click.STYLE_FOOTER_TEXT = "dim" +click.rich_click.STYLE_OPTIONS_PANEL_BORDER = "dim" +click.rich_click.ALIGN_OPTIONS_PANEL = "left" +click.rich_click.MAX_WIDTH = 100 + +# Option groups for better organization +click.rich_click.OPTION_GROUPS = { + "planoai up": [ + { + "name": "Configuration", + "options": ["--path", "file"], + }, + { + "name": "Runtime Options", + "options": ["--foreground"], + }, + ], + "planoai logs": [ + { + "name": "Log Options", + "options": ["--debug", "--follow"], + }, + ], +} + +# Command groups for main help +click.rich_click.COMMAND_GROUPS = { + "planoai": [ + { + "name": "Gateway Commands", + "commands": ["up", "down", "build", "logs"], + }, + { + "name": "Agent Commands", + "commands": ["cli-agent"], + }, + { + "name": "Utilities", + "commands": ["validate", "generate-prompt-targets"], + }, + ], +} from planoai.docker_cli import ( docker_validate_plano_schema, stream_gateway_logs, @@ -34,19 +102,22 @@ from planoai.consts import ( log = getLogger(__name__) # ref https://patorjk.com/software/taag/#p=display&f=Doom&t=Plano&x=none&v=4&h=4&w=80&we=false -logo = r""" -______ _ -| ___ \ | -| |_/ / | __ _ _ __ ___ -| __/| |/ _` | '_ \ / _ \ -| | | | (_| | | | | (_) | -\_| |_|\__,_|_| |_|\___/ - -""" +LOGO = f"""[bold {PLANO_COLOR}] + ______ _ + | ___ \\ | + | |_/ / | __ _ _ __ ___ + | __/| |/ _` | '_ \\ / _ \\ + | | | | (_| | | | | (_) | + \\_| |_|\\__,_|_| |_|\\___/ +[/bold {PLANO_COLOR}]""" # Command to build plano Docker images ARCHGW_DOCKERFILE = "./Dockerfile" +# PyPI package name for version checking +PYPI_PACKAGE_NAME = "planoai" +PYPI_URL = f"https://pypi.org/pypi/{PYPI_PACKAGE_NAME}/json" + def get_version(): try: @@ -63,19 +134,110 @@ def get_version(): return "version not found" +def get_latest_version(timeout: float = 2.0) -> str | None: + """Fetch the latest version from PyPI. + + Args: + timeout: Request timeout in seconds + + Returns: + Latest version string or None if fetch failed + """ + import requests + + try: + response = requests.get(PYPI_URL, timeout=timeout) + if response.status_code == 200: + data = response.json() + return data.get("info", {}).get("version") + except (requests.RequestException, ValueError): + # Network error or invalid JSON - fail silently + pass + return None + + +def parse_version(version_str: str) -> tuple: + """Parse version string into comparable tuple. + + Handles versions like "0.4.1", "1.0.0", "0.4.1a1" + """ + import re + + # Remove any pre-release suffixes for comparison + clean_version = re.split(r"[a-zA-Z]", version_str)[0] + parts = clean_version.split(".") + return tuple(int(p) for p in parts if p.isdigit()) + + +def check_version_status(current: str, latest: str | None) -> dict: + """Compare current version with latest and return status. + + Returns: + dict with keys: is_outdated, current, latest, message + """ + if latest is None: + return { + "is_outdated": False, + "current": current, + "latest": None, + "message": None, + } + + try: + current_tuple = parse_version(current) + latest_tuple = parse_version(latest) + is_outdated = current_tuple < latest_tuple + + return { + "is_outdated": is_outdated, + "current": current, + "latest": latest, + "message": f"Update available: {latest}" if is_outdated else None, + } + except (ValueError, TypeError): + # Version parsing failed + return { + "is_outdated": False, + "current": current, + "latest": latest, + "message": None, + } + + @click.group(invoke_without_command=True) -@click.option("--version", is_flag=True, help="Show the plano cli version and exit.") +@click.option("--version", is_flag=True, help="Show the Plano CLI version and exit.") @click.pass_context def main(ctx, version): + from rich.console import Console + + console = Console() + if version: - click.echo(f"plano cli version: {get_version()}") + current_version = get_version() + console.print( + f"[bold {PLANO_COLOR}]plano[/bold {PLANO_COLOR}] version [cyan]{current_version}[/cyan]" + ) + + # Check for updates (skip if PLANO_SKIP_VERSION_CHECK is set) + if not os.environ.get("PLANO_SKIP_VERSION_CHECK"): + latest_version = get_latest_version() + status = check_version_status(current_version, latest_version) + + if status["is_outdated"]: + console.print( + f"\n[yellow]⚠ Update available:[/yellow] [bold]{status['latest']}[/bold]" + ) + console.print( + f"[dim]Run: uv pip install --upgrade {PYPI_PACKAGE_NAME}[/dim]" + ) + elif latest_version: + console.print(f"[dim]✓ You're up to date[/dim]") + ctx.exit() - log.info(f"Starting plano cli version: {get_version()}") - if ctx.invoked_subcommand is None: - click.echo("""Arch (The Intelligent Prompt Gateway) CLI""") - click.echo(logo) + console.print(LOGO) + console.print("[dim]The Delivery Infrastructure for Agentic Apps[/dim]\n") click.echo(ctx.get_help()) @@ -121,16 +283,16 @@ def build(): @click.command() @click.argument("file", required=False) # Optional file argument @click.option( - "--path", default=".", help="Path to the directory containing arch_config.yaml" + "--path", default=".", help="Path to the directory containing config.yaml" ) @click.option( "--foreground", default=False, - help="Run Arch in the foreground. Default is False", + help="Run Plano in the foreground. Default is False", is_flag=True, ) def up(file, path, foreground): - """Starts Arch.""" + """Starts Plano.""" # Use the utility function to find config file arch_config_file = find_config_file(path, file) @@ -198,7 +360,7 @@ def up(file, path, foreground): @click.command() def down(): - """Stops Arch.""" + """Stops Plano.""" stop_docker_container() @@ -270,7 +432,7 @@ def logs(debug, follow): help="Additional settings as JSON string for the CLI agent.", ) def cli_agent(type, file, path, settings): - """Start a CLI agent connected to Arch. + """Start a CLI agent connected to Plano. Currently only 'claude' is supported. CLI_AGENT: The type of CLI agent to start (currently only 'claude' is supported) """ @@ -298,12 +460,258 @@ def cli_agent(type, file, path, settings): sys.exit(1) +def validate_config_file(config_path: str) -> dict: + """Validate a Plano config file and return validation results. + + Args: + config_path: Path to the config file + + Returns: + dict with keys: valid, errors, warnings, config, summary + """ + import yaml + from jsonschema import validate as json_validate, ValidationError + + result = { + "valid": True, + "errors": [], + "warnings": [], + "config": None, + "summary": { + "model_providers": [], + "listeners": [], + "env_vars_required": [], + }, + } + + # Check file exists + if not os.path.exists(config_path): + result["valid"] = False + result["errors"].append(f"Config file not found: {config_path}") + return result + + # Try to load YAML + try: + with open(config_path, "r") as f: + config = yaml.safe_load(f) + result["config"] = config + except yaml.YAMLError as e: + result["valid"] = False + result["errors"].append(f"Invalid YAML syntax: {e}") + return result + + # Find schema file + schema_path = find_repo_root() + if schema_path: + schema_file = os.path.join(schema_path, "config", "arch_config_schema.yaml") + else: + # Fallback - try relative paths + schema_file = None + for possible_path in [ + "../config/arch_config_schema.yaml", + "config/arch_config_schema.yaml", + ]: + if os.path.exists(possible_path): + schema_file = possible_path + break + + # Schema validation + if schema_file and os.path.exists(schema_file): + try: + with open(schema_file, "r") as f: + schema = yaml.safe_load(f) + json_validate(config, schema) + except ValidationError as e: + result["valid"] = False + result["errors"].append(f"Schema validation failed: {e.message}") + except Exception as e: + result["warnings"].append(f"Could not validate schema: {e}") + else: + result["warnings"].append("Schema file not found, skipping schema validation") + + # Extract model providers + model_providers = config.get("model_providers", config.get("llm_providers", [])) + for provider in model_providers: + model = provider.get("model", "unknown") + is_default = provider.get("default", False) + result["summary"]["model_providers"].append( + { + "model": model, + "default": is_default, + "name": provider.get("name", model), + } + ) + + # Check for env vars + access_key = provider.get("access_key", "") + if access_key.startswith("$"): + env_var = access_key[1:] + result["summary"]["env_vars_required"].append(env_var) + + # Extract listeners + listeners = config.get("listeners", {}) + if isinstance(listeners, dict): + # Legacy format + if "egress_traffic" in listeners: + result["summary"]["listeners"].append( + { + "name": "egress_traffic (LLM Gateway)", + "port": listeners["egress_traffic"].get("port", 12000), + "type": "model", + } + ) + if "ingress_traffic" in listeners: + result["summary"]["listeners"].append( + { + "name": "ingress_traffic (Prompt Gateway)", + "port": listeners["ingress_traffic"].get("port", 10000), + "type": "prompt", + } + ) + elif isinstance(listeners, list): + for listener in listeners: + result["summary"]["listeners"].append( + { + "name": listener.get("name", "unnamed"), + "port": listener.get("port", "unknown"), + "type": listener.get("type", "unknown"), + } + ) + + # Remove duplicates from env vars first + result["summary"]["env_vars_required"] = list( + set(result["summary"]["env_vars_required"]) + ) + + # Check environment variables (after deduplication) + for env_var in result["summary"]["env_vars_required"]: + if not os.environ.get(env_var): + result["warnings"].append(f"Environment variable ${env_var} is not set") + + return result + + +@click.command() +@click.argument("config_file", required=False, type=click.Path()) +@click.option( + "--path", "-p", default=".", help="Path to directory containing config.yaml" +) +@click.option("--quiet", "-q", is_flag=True, help="Only show errors, no summary") +def validate(config_file, path, quiet): + """Validate a Plano configuration file. + + If no CONFIG_FILE is provided, looks for config.yaml in the current directory + or the directory specified by --path. + """ + from rich.console import Console + from rich.table import Table + from rich.panel import Panel + + console = Console() + + # Determine config file path + if config_file: + config_path = os.path.abspath(config_file) + else: + # Look for config.yaml in path + config_path = os.path.join(os.path.abspath(path), "config.yaml") + if not os.path.exists(config_path): + # Try arch_config.yaml as fallback + config_path = os.path.join(os.path.abspath(path), "arch_config.yaml") + + # Show what we're validating + console.print(f"\n[bold]Validating:[/bold] [dim]{config_path}[/dim]\n") + + # Run validation + result = validate_config_file(config_path) + + # Display results + if result["valid"]: + console.print( + f"[bold green]✓[/bold green] [green]Configuration is valid[/green]\n" + ) + else: + console.print(f"[bold red]✗[/bold red] [red]Configuration is invalid[/red]\n") + + # Show errors + if result["errors"]: + for error in result["errors"]: + console.print(f" [red]✗ {error}[/red]") + console.print() + + # Show warnings + if result["warnings"]: + for warning in result["warnings"]: + console.print(f" [yellow]⚠ {warning}[/yellow]") + console.print() + + # Show summary (unless quiet mode) + if not quiet and result["config"]: + summary = result["summary"] + + # Model Providers table + if summary["model_providers"]: + table = Table( + title=f"[bold {PLANO_COLOR}]Model Providers[/bold {PLANO_COLOR}]", + border_style="dim", + show_header=True, + header_style=f"bold {PLANO_COLOR}", + ) + table.add_column("Model", style="cyan") + table.add_column("Default", style="green", justify="center") + + for provider in summary["model_providers"]: + default_marker = "●" if provider["default"] else "" + table.add_row(provider["model"], default_marker) + + console.print(table) + console.print() + + # Listeners table + if summary["listeners"]: + table = Table( + title=f"[bold {PLANO_COLOR}]Listeners[/bold {PLANO_COLOR}]", + border_style="dim", + show_header=True, + header_style=f"bold {PLANO_COLOR}", + ) + table.add_column("Name", style="cyan") + table.add_column("Type", style="magenta") + table.add_column("Port", style="yellow", justify="right") + + for listener in summary["listeners"]: + table.add_row(listener["name"], listener["type"], str(listener["port"])) + + console.print(table) + console.print() + + # Environment variables + if summary["env_vars_required"]: + env_status = [] + for env_var in sorted(summary["env_vars_required"]): + is_set = os.environ.get(env_var) is not None + status = f"[green]✓[/green]" if is_set else f"[yellow]○[/yellow]" + env_status.append(f" {status} [dim]${env_var}[/dim]") + + console.print( + f"[bold {PLANO_COLOR}]Environment Variables[/bold {PLANO_COLOR}]" + ) + for line in env_status: + console.print(line) + console.print() + + # Exit with appropriate code + if not result["valid"]: + sys.exit(1) + + main.add_command(up) main.add_command(down) main.add_command(build) main.add_command(logs) main.add_command(cli_agent) main.add_command(generate_prompt_targets) +main.add_command(validate) if __name__ == "__main__": main() diff --git a/cli/pyproject.toml b/cli/pyproject.toml index f9108178..be5ba0ca 100644 --- a/cli/pyproject.toml +++ b/cli/pyproject.toml @@ -11,6 +11,8 @@ dependencies = [ "jsonschema>=4.23.0,<5.0.0", "pyyaml>=6.0.2,<7.0.0", "requests>=2.31.0,<3.0.0", + "rich>=14.2.0", + "rich-click>=1.9.5", ] [project.optional-dependencies] diff --git a/cli/test/test_validate.py b/cli/test/test_validate.py new file mode 100644 index 00000000..9e64fcda --- /dev/null +++ b/cli/test/test_validate.py @@ -0,0 +1,218 @@ +import pytest +import os +import tempfile +from unittest import mock +from click.testing import CliRunner +from planoai.main import validate, validate_config_file + + +class TestValidateConfigFile: + """Tests for the validate_config_file function.""" + + def test_valid_config(self, tmp_path): + """Test validation of a valid config file.""" + config_content = """ +version: v0.3.0 + +listeners: + - type: model + name: llm_gateway + port: 12000 + +model_providers: + - model: openai/gpt-4o-mini + access_key: $OPENAI_API_KEY + default: true +""" + config_file = tmp_path / "config.yaml" + config_file.write_text(config_content) + + result = validate_config_file(str(config_file)) + + assert result["valid"] is True + assert len(result["errors"]) == 0 + assert result["config"] is not None + assert len(result["summary"]["model_providers"]) == 1 + assert result["summary"]["model_providers"][0]["model"] == "openai/gpt-4o-mini" + assert result["summary"]["model_providers"][0]["default"] is True + assert "OPENAI_API_KEY" in result["summary"]["env_vars_required"] + + def test_invalid_yaml_syntax(self, tmp_path): + """Test validation fails for invalid YAML syntax.""" + config_content = """ +version: v0.3.0 +listeners: + - type: model + name: [invalid yaml +""" + config_file = tmp_path / "config.yaml" + config_file.write_text(config_content) + + result = validate_config_file(str(config_file)) + + assert result["valid"] is False + assert any("Invalid YAML" in error for error in result["errors"]) + + def test_file_not_found(self): + """Test validation fails for non-existent file.""" + result = validate_config_file("/nonexistent/path/config.yaml") + + assert result["valid"] is False + assert any("not found" in error for error in result["errors"]) + + def test_multiple_model_providers(self, tmp_path): + """Test config with multiple model providers.""" + config_content = """ +version: v0.3.0 + +listeners: + - type: model + name: llm_gateway + port: 12000 + +model_providers: + - model: openai/gpt-4o-mini + access_key: $OPENAI_API_KEY + default: true + + - model: anthropic/claude-3-5-sonnet + access_key: $ANTHROPIC_API_KEY + + - model: mistral/mistral-large + access_key: $MISTRAL_API_KEY +""" + config_file = tmp_path / "config.yaml" + config_file.write_text(config_content) + + result = validate_config_file(str(config_file)) + + assert result["valid"] is True + assert len(result["summary"]["model_providers"]) == 3 + assert set(result["summary"]["env_vars_required"]) == { + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "MISTRAL_API_KEY", + } + + def test_legacy_listener_format(self, tmp_path): + """Test config with legacy listener format.""" + config_content = """ +version: v0.1.0 + +listeners: + egress_traffic: + address: 0.0.0.0 + port: 12000 + ingress_traffic: + address: 0.0.0.0 + port: 10000 + +llm_providers: + - model: openai/gpt-4o + access_key: $OPENAI_API_KEY +""" + config_file = tmp_path / "config.yaml" + config_file.write_text(config_content) + + result = validate_config_file(str(config_file)) + + assert result["valid"] is True + assert len(result["summary"]["listeners"]) == 2 + + # Check listener ports are correctly extracted + ports = [l["port"] for l in result["summary"]["listeners"]] + assert 12000 in ports + assert 10000 in ports + + +class TestValidateCommand: + """Tests for the CLI validate command.""" + + def test_validate_command_with_valid_file(self, tmp_path): + """Test validate command with a valid config file.""" + config_content = """ +version: v0.3.0 + +listeners: + - type: model + name: llm_gateway + port: 12000 + +model_providers: + - model: openai/gpt-4o-mini + access_key: $OPENAI_API_KEY + default: true +""" + config_file = tmp_path / "config.yaml" + config_file.write_text(config_content) + + runner = CliRunner() + result = runner.invoke(validate, [str(config_file)]) + + assert result.exit_code == 0 + assert "valid" in result.output.lower() or "✓" in result.output + + def test_validate_command_with_invalid_file(self, tmp_path): + """Test validate command fails with invalid config.""" + config_content = """ +version: v0.3.0 +invalid_yaml: [broken +""" + config_file = tmp_path / "config.yaml" + config_file.write_text(config_content) + + runner = CliRunner() + result = runner.invoke(validate, [str(config_file)]) + + assert result.exit_code == 1 + assert "invalid" in result.output.lower() or "✗" in result.output + + def test_validate_command_auto_detect_config(self, tmp_path, monkeypatch): + """Test validate command auto-detects config.yaml in current directory.""" + config_content = """ +version: v0.3.0 + +listeners: + - type: model + name: test + port: 12000 + +model_providers: + - model: openai/gpt-4o + access_key: $OPENAI_API_KEY +""" + config_file = tmp_path / "config.yaml" + config_file.write_text(config_content) + + # Change to the temp directory + monkeypatch.chdir(tmp_path) + + runner = CliRunner() + result = runner.invoke(validate, []) + + assert result.exit_code == 0 + assert "valid" in result.output.lower() or "✓" in result.output + + def test_validate_command_quiet_mode(self, tmp_path): + """Test validate command with --quiet flag.""" + config_content = """ +version: v0.3.0 + +listeners: + - type: model + name: llm_gateway + port: 12000 + +model_providers: + - model: openai/gpt-4o-mini + access_key: $OPENAI_API_KEY +""" + config_file = tmp_path / "config.yaml" + config_file.write_text(config_content) + + runner = CliRunner() + result = runner.invoke(validate, [str(config_file), "--quiet"]) + + assert result.exit_code == 0 + # Quiet mode should have minimal output (no tables) + assert "Model Providers" not in result.output diff --git a/cli/test/test_version_check.py b/cli/test/test_version_check.py new file mode 100644 index 00000000..c51eeae5 --- /dev/null +++ b/cli/test/test_version_check.py @@ -0,0 +1,163 @@ +import pytest +from unittest import mock +from planoai.main import ( + get_version, + get_latest_version, + parse_version, + check_version_status, + PYPI_URL, +) + + +class TestParseVersion: + """Tests for version string parsing.""" + + def test_parse_simple_version(self): + assert parse_version("1.0.0") == (1, 0, 0) + assert parse_version("0.4.1") == (0, 4, 1) + assert parse_version("10.20.30") == (10, 20, 30) + + def test_parse_two_part_version(self): + assert parse_version("1.0") == (1, 0) + assert parse_version("2.5") == (2, 5) + + def test_parse_version_with_prerelease(self): + # Pre-release suffixes should be stripped + assert parse_version("0.4.1a1") == (0, 4, 1) + assert parse_version("1.0.0beta2") == (1, 0, 0) + assert parse_version("2.0.0rc1") == (2, 0, 0) + + +class TestCheckVersionStatus: + """Tests for version comparison logic.""" + + def test_current_equals_latest(self): + status = check_version_status("0.4.1", "0.4.1") + assert status["is_outdated"] is False + assert status["current"] == "0.4.1" + assert status["latest"] == "0.4.1" + assert status["message"] is None + + def test_current_is_outdated(self): + status = check_version_status("0.4.1", "0.5.0") + assert status["is_outdated"] is True + assert status["current"] == "0.4.1" + assert status["latest"] == "0.5.0" + assert "Update available" in status["message"] + assert "0.5.0" in status["message"] + + def test_current_is_newer(self): + # Dev version might be newer than PyPI + status = check_version_status("0.5.0", "0.4.1") + assert status["is_outdated"] is False + assert status["message"] is None + + def test_major_version_outdated(self): + status = check_version_status("0.4.1", "1.0.0") + assert status["is_outdated"] is True + + def test_minor_version_outdated(self): + status = check_version_status("0.4.1", "0.5.0") + assert status["is_outdated"] is True + + def test_patch_version_outdated(self): + status = check_version_status("0.4.1", "0.4.2") + assert status["is_outdated"] is True + + def test_latest_is_none(self): + # When PyPI check fails + status = check_version_status("0.4.1", None) + assert status["is_outdated"] is False + assert status["latest"] is None + assert status["message"] is None + + +class TestGetLatestVersion: + """Tests for PyPI version fetching.""" + + def test_successful_fetch(self): + mock_response = mock.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"info": {"version": "0.5.0"}} + + with mock.patch("requests.get", return_value=mock_response): + version = get_latest_version() + assert version == "0.5.0" + + def test_network_error(self): + import requests + + with mock.patch( + "requests.get", side_effect=requests.RequestException("Network error") + ): + version = get_latest_version() + assert version is None + + def test_timeout(self): + import requests + + with mock.patch("requests.get", side_effect=requests.Timeout("Timeout")): + version = get_latest_version() + assert version is None + + def test_invalid_json(self): + mock_response = mock.Mock() + mock_response.status_code = 200 + mock_response.json.side_effect = ValueError("Invalid JSON") + + with mock.patch("requests.get", return_value=mock_response): + version = get_latest_version() + assert version is None + + def test_404_response(self): + mock_response = mock.Mock() + mock_response.status_code = 404 + + with mock.patch("requests.get", return_value=mock_response): + version = get_latest_version() + assert version is None + + +class TestVersionCheckIntegration: + """Integration tests simulating version check scenarios.""" + + def test_outdated_version_message(self, capsys): + """Simulate an outdated version scenario.""" + from rich.console import Console + + console = Console(force_terminal=True) + current_version = "0.4.1" + + # Mock PyPI returning a newer version + mock_response = mock.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"info": {"version": "0.5.0"}} + + with mock.patch("requests.get", return_value=mock_response): + latest = get_latest_version() + status = check_version_status(current_version, latest) + + assert status["is_outdated"] is True + assert status["latest"] == "0.5.0" + + def test_up_to_date_version(self): + """Simulate an up-to-date version scenario.""" + current_version = "0.4.1" + + mock_response = mock.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"info": {"version": "0.4.1"}} + + with mock.patch("requests.get", return_value=mock_response): + latest = get_latest_version() + status = check_version_status(current_version, latest) + + assert status["is_outdated"] is False + + def test_skip_version_check_env_var(self, monkeypatch): + """Test that PLANO_SKIP_VERSION_CHECK skips the check.""" + monkeypatch.setenv("PLANO_SKIP_VERSION_CHECK", "1") + + import os + + assert os.environ.get("PLANO_SKIP_VERSION_CHECK") == "1" diff --git a/cli/uv.lock b/cli/uv.lock index fffd3eb2..764badcc 100644 --- a/cli/uv.lock +++ b/cli/uv.lock @@ -174,6 +174,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + [[package]] name = "markupsafe" version = "3.0.2" @@ -232,6 +244,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -251,6 +272,8 @@ dependencies = [ { name = "jsonschema" }, { name = "pyyaml" }, { name = "requests" }, + { name = "rich" }, + { name = "rich-click" }, ] [package.optional-dependencies] @@ -271,6 +294,8 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.1,<9.0.0" }, { name = "pyyaml", specifier = ">=6.0.2,<7.0.0" }, { name = "requests", specifier = ">=2.31.0,<3.0.0" }, + { name = "rich", specifier = ">=14.2.0" }, + { name = "rich-click", specifier = ">=1.9.5" }, ] provides-extras = ["dev"] @@ -386,6 +411,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "rich-click" +version = "1.9.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/d1/b60ca6a8745e76800b50c7ee246fd73f08a3be5d8e0b551fc93c19fa1203/rich_click-1.9.5.tar.gz", hash = "sha256:48120531493f1533828da80e13e839d471979ec8d7d0ca7b35f86a1379cc74b6", size = 73927, upload-time = "2025-12-21T14:49:44.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/0a/d865895e1e5d88a60baee0fc3703eb111c502ee10c8c107516bc7623abf8/rich_click-1.9.5-py3-none-any.whl", hash = "sha256:9b195721a773b1acf0e16ff9ec68cef1e7d237e53471e6e3f7ade462f86c403a", size = 70580, upload-time = "2025-12-21T14:49:42.905Z" }, +] + [[package]] name = "rpds-py" version = "0.27.1"