diff --git a/concore_cli/cli.py b/concore_cli/cli.py index 1580183..92ca6e5 100644 --- a/concore_cli/cli.py +++ b/concore_cli/cli.py @@ -10,6 +10,7 @@ from .commands.stop import stop_all from .commands.inspect import inspect_workflow from .commands.watch import watch_study +from .commands.doctor import doctor_check from . import __version__ console = Console() @@ -118,5 +119,17 @@ def watch(study_dir, interval, once): sys.exit(1) +@cli.command() +def doctor(): + """Check system readiness for running concore studies""" + try: + ok = doctor_check(console) + if not ok: + sys.exit(1) + except Exception as e: + console.print(f"[red]Error:[/red] {str(e)}") + sys.exit(1) + + if __name__ == "__main__": cli() diff --git a/concore_cli/commands/__init__.py b/concore_cli/commands/__init__.py index e98d4cd..b9771e4 100644 --- a/concore_cli/commands/__init__.py +++ b/concore_cli/commands/__init__.py @@ -4,6 +4,7 @@ from .status import show_status from .stop import stop_all from .watch import watch_study +from .doctor import doctor_check __all__ = [ "init_project", @@ -12,4 +13,5 @@ "show_status", "stop_all", "watch_study", + "doctor_check", ] diff --git a/concore_cli/commands/doctor.py b/concore_cli/commands/doctor.py new file mode 100644 index 0000000..254edfd --- /dev/null +++ b/concore_cli/commands/doctor.py @@ -0,0 +1,411 @@ +import importlib.metadata +import shutil +import subprocess +import sys +import os +import platform +from pathlib import Path +from rich.panel import Panel + +# Map of tool keys to their lookup names per platform +TOOL_DEFINITIONS = { + "C++ compiler": { + "names": { + "posix": ["g++", "clang++"], + "windows": ["g++", "cl"], + }, + "version_flag": "--version", + "config_keys": ["CPPEXE", "CPPWIN"], + "install_hints": { + "Linux": "sudo apt install g++", + "Darwin": "brew install gcc", + "Windows": "winget install -e --id GnuWin32.Gcc", + }, + }, + "Python": { + "names": { + "posix": ["python3", "python"], + "windows": ["python", "python3"], + }, + "version_flag": "--version", + "config_keys": ["PYTHONEXE", "PYTHONWIN"], + "install_hints": { + "Linux": "sudo apt install python3", + "Darwin": "brew install python3", + "Windows": "winget install -e --id Python.Python.3.11", + }, + }, + "Verilog (iverilog)": { + "names": { + "posix": ["iverilog"], + "windows": ["iverilog"], + }, + "version_flag": "-V", + "config_keys": ["VEXE", "VWIN"], + "install_hints": { + "Linux": "sudo apt install iverilog", + "Darwin": "brew install icarus-verilog", + "Windows": "Download from http://bleyer.org/icarus/", + }, + }, + "Octave": { + "names": { + "posix": ["octave", "octave-cli"], + "windows": ["octave", "octave-cli"], + }, + "version_flag": "--version", + "config_keys": ["OCTAVEEXE", "OCTAVEWIN"], + "install_hints": { + "Linux": "sudo apt install octave", + "Darwin": "brew install octave", + "Windows": "winget install -e --id JohnWHiggins.Octave", + }, + }, + "MATLAB": { + "names": { + "posix": ["matlab"], + "windows": ["matlab"], + }, + "version_flag": ["-batch", "disp('ok')"], + "config_keys": ["MATLABEXE", "MATLABWIN"], + "install_hints": { + "Linux": "Install from https://mathworks.com/downloads/", + "Darwin": "Install from https://mathworks.com/downloads/", + "Windows": "Install from https://mathworks.com/downloads/", + }, + }, + "Docker": { + "names": { + "posix": ["docker", "podman"], + "windows": ["docker", "podman"], + }, + "version_flag": "--version", + "config_keys": [], + "install_hints": { + "Linux": "sudo apt install docker.io", + "Darwin": "brew install --cask docker", + "Windows": "winget install -e --id Docker.DockerDesktop", + }, + }, +} + +REQUIRED_PACKAGES = [ + "click", + "rich", + "beautifulsoup4", + "lxml", + "psutil", + "numpy", + "pyzmq", +] + +OPTIONAL_PACKAGES = { + "scipy": "pip install concore[demo]", + "matplotlib": "pip install concore[demo]", +} + + +def _get_platform_key(): + """Return 'posix' or 'windows' based on OS.""" + return "windows" if os.name == "nt" else "posix" + + +def _get_platform_name(): + """Return platform name for install hint lookup.""" + return platform.system() + + +def _resolve_concore_path(): + """Resolve CONCOREPATH the same way mkconcore.py does.""" + script_dir = Path(__file__).resolve().parent.parent.parent + if (script_dir / "concore.py").exists(): + return script_dir + cwd = Path.cwd() + if (cwd / "concore.py").exists(): + return cwd + return script_dir + + +def _detect_tool(names): + """Try to find a tool by checking a list of candidate names. + + Returns (path, name) of the first match, or (None, None). + """ + for name in names: + path = shutil.which(name) + if path: + return path, name + return None, None + + +def _get_version(path, version_flag): + """Run tool with version flag and return first line of output.""" + try: + if isinstance(version_flag, list): + cmd = [path] + version_flag + else: + cmd = [path, version_flag] + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=10, + ) + output = result.stdout.strip() or result.stderr.strip() + if output: + return output.splitlines()[0] + except Exception: + pass + return None + + +def _check_docker_daemon(docker_path): + """Check if Docker daemon is running.""" + try: + result = subprocess.run( + [docker_path, "info"], + capture_output=True, + text=True, + timeout=15, + ) + return result.returncode == 0 + except Exception: + return False + + +def _check_package(package_name): + """Check if a Python package is importable and get its version.""" + try: + version = importlib.metadata.version(package_name) + return True, version + except importlib.metadata.PackageNotFoundError: + return False, None + + +def doctor_check(console): + """Run system readiness checks and display results.""" + passed = 0 + warnings = 0 + errors = 0 + + console.print() + console.print( + Panel.fit( + "[bold]concore Doctor - System Readiness Report[/bold]", + border_style="cyan", + ) + ) + console.print() + + # === Core Checks === + console.print("[bold cyan]Core Checks[/bold cyan]") + + # Python version + py_version = platform.python_version() + if sys.version_info >= (3, 9): + console.print(f" [green]+[/green] Python {py_version} (>= 3.9 required)") + passed += 1 + else: + console.print( + f" [red]x[/red] Python {py_version} - concore requires Python >= 3.9" + ) + errors += 1 + + # concore installation + try: + from concore_cli import __version__ + + console.print(f" [green]+[/green] concore {__version__} installed") + passed += 1 + except ImportError: + console.print(" [red]x[/red] concore package not found") + errors += 1 + + # CONCOREPATH + concore_path = _resolve_concore_path() + if concore_path.exists(): + writable = os.access(str(concore_path), os.W_OK) + status = "writable" if writable else "read-only" + if writable: + console.print(f" [green]+[/green] CONCOREPATH: {concore_path} ({status})") + passed += 1 + else: + console.print( + f" [yellow]![/yellow] CONCOREPATH: {concore_path} ({status})" + ) + warnings += 1 + else: + console.print(f" [red]x[/red] CONCOREPATH: {concore_path} (not found)") + errors += 1 + + console.print() + + # === Tool Detection === + console.print("[bold cyan]Tools[/bold cyan]") + + plat_key = _get_platform_key() + plat_name = _get_platform_name() + + for tool_label, tool_def in TOOL_DEFINITIONS.items(): + candidates = tool_def["names"].get(plat_key, []) + path, found_name = _detect_tool(candidates) + + if path: + version = _get_version(path, tool_def["version_flag"]) + version_str = f" ({version})" if version else "" + exe_info = f" [{found_name}]" if found_name else "" + extra = "" + if tool_label == "Docker": + daemon_ok = _check_docker_daemon(path) + extra = ( + " [green](daemon running)[/green]" + if daemon_ok + else " [yellow](daemon not running)[/yellow]" + ) + if not daemon_ok: + warnings += 1 + console.print( + f" [yellow]![/yellow] {tool_label}{exe_info}" + f"{version_str} -> {path}{extra}" + ) + continue + console.print( + f" [green]+[/green] {tool_label}{exe_info}" + f"{version_str} -> {path}{extra}" + ) + passed += 1 + else: + hint = tool_def["install_hints"].get(plat_name, "") + hint_str = f" (install: {hint})" if hint else "" + # Docker, MATLAB, Verilog are optional - show as warning + if tool_label in ("MATLAB", "Verilog (iverilog)", "Docker"): + console.print( + f" [yellow]![/yellow] {tool_label} -> Not found{hint_str}" + ) + warnings += 1 + else: + console.print(f" [red]x[/red] {tool_label} -> Not found{hint_str}") + errors += 1 + + console.print() + + # === Configuration Checks === + console.print("[bold cyan]Configuration[/bold cyan]") + + config_files = { + "concore.tools": "Tool path overrides", + "concore.octave": "Treat .m files as Octave", + "concore.mcr": "MATLAB Compiler Runtime path", + "concore.repo": "Docker repository path", + "concore.sudo": "Docker executable override", + } + + for filename, description in config_files.items(): + filepath = concore_path / filename + if filepath.exists(): + try: + content = filepath.read_text().strip() + if filename == "concore.tools": + line_count = len( + [ + ln + for ln in content.splitlines() + if ln.strip() and not ln.strip().startswith("#") + ] + ) + console.print( + f" [green]+[/green] {filename} -> " + f"{line_count} tool path(s) configured" + ) + elif filename == "concore.mcr": + if os.path.exists(os.path.expanduser(content)): + console.print(f" [green]+[/green] {filename} -> {content}") + passed += 1 + else: + console.print( + f" [yellow]![/yellow] {filename} -> " + f"path does not exist: {content}" + ) + warnings += 1 + continue + elif filename == "concore.sudo": + console.print(f" [green]+[/green] {filename} -> {content}") + elif filename == "concore.repo": + console.print(f" [green]+[/green] {filename} -> {content}") + else: + console.print(f" [green]+[/green] {filename} -> Enabled") + passed += 1 + except Exception: + console.print(f" [yellow]![/yellow] {filename} -> Could not read") + warnings += 1 + else: + console.print(f" [dim]-[/dim] {filename} -> Not set ({description})") + + # Build environment variable list from TOOL_DEFINITIONS config_keys + env_vars = [] + for tool_def in TOOL_DEFINITIONS.values(): + for key in tool_def.get("config_keys", []): + env_vars.append(f"CONCORE_{key}") + env_vars.append("DOCKEREXE") + env_set = [v for v in env_vars if os.environ.get(v)] + if env_set: + console.print(f" [green]+[/green] Environment variables: {', '.join(env_set)}") + passed += 1 + else: + console.print(" [dim]-[/dim] No concore environment variables set") + + console.print() + + # === Dependency Checks === + console.print("[bold cyan]Dependencies[/bold cyan]") + + for pkg in REQUIRED_PACKAGES: + found, version = _check_package(pkg) + if found: + console.print(f" [green]+[/green] {pkg} {version}") + passed += 1 + else: + console.print(f" [red]x[/red] {pkg} -> Not installed (pip install {pkg})") + errors += 1 + + for pkg, install_hint in OPTIONAL_PACKAGES.items(): + found, version = _check_package(pkg) + if found: + console.print(f" [green]+[/green] {pkg} {version}") + passed += 1 + else: + console.print( + f" [yellow]![/yellow] {pkg} -> Not installed ({install_hint})" + ) + warnings += 1 + + console.print() + + # === Summary === + summary_parts = [] + if passed: + summary_parts.append(f"[green]{passed} passed[/green]") + if warnings: + summary_parts.append(f"[yellow]{warnings} warning(s)[/yellow]") + if errors: + summary_parts.append(f"[red]{errors} error(s)[/red]") + + if summary_parts: + summary_text = ", ".join(summary_parts) + else: + summary_text = "[yellow]No checks were run.[/yellow]" + + console.print(f"[bold]Summary:[/bold] {summary_text}") + + if errors == 0: + console.print() + console.print( + Panel.fit( + "[green]System is ready to run concore studies![/green]", + border_style="green", + ) + ) + + console.print() + + return errors == 0 diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 0000000..e9ec584 --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,213 @@ +import unittest +import tempfile +import shutil +from pathlib import Path +from unittest.mock import patch +from click.testing import CliRunner +from concore_cli.cli import cli +from concore_cli.commands.doctor import ( + _detect_tool, + _get_platform_key, + _check_package, + _resolve_concore_path, + doctor_check, +) + + +class TestDoctorCommand(unittest.TestCase): + """Tests for the concore doctor CLI command.""" + + def setUp(self): + self.runner = CliRunner() + + def test_doctor_command_runs(self): + """Doctor command should run and produce output.""" + result = self.runner.invoke(cli, ["doctor"]) + self.assertIn("concore Doctor", result.output) + self.assertIn("Core Checks", result.output) + self.assertIn("Tools", result.output) + self.assertIn("Configuration", result.output) + self.assertIn("Dependencies", result.output) + self.assertIn("Summary", result.output) + + def test_doctor_help(self): + """Doctor command should have help text.""" + result = self.runner.invoke(cli, ["doctor", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("Check system readiness", result.output) + + def test_doctor_shows_python_version(self): + """Doctor should show the current Python version.""" + result = self.runner.invoke(cli, ["doctor"]) + import platform + + py_version = platform.python_version() + self.assertIn(py_version, result.output) + + def test_doctor_shows_concore_version(self): + """Doctor should detect and show concore version.""" + from concore_cli import __version__ + + result = self.runner.invoke(cli, ["doctor"]) + self.assertIn("concore", result.output) + self.assertIn(__version__, result.output) + + def test_doctor_shows_concorepath(self): + """Doctor should show the CONCOREPATH.""" + result = self.runner.invoke(cli, ["doctor"]) + self.assertIn("CONCOREPATH", result.output) + + def test_doctor_checks_dependencies(self): + """Doctor should check required Python packages.""" + result = self.runner.invoke(cli, ["doctor"]) + # These should be installed since we're running tests + self.assertIn("click", result.output) + self.assertIn("rich", result.output) + + def test_doctor_shows_summary(self): + """Doctor should show a summary with pass/warn/error counts.""" + result = self.runner.invoke(cli, ["doctor"]) + self.assertIn("Summary", result.output) + self.assertIn("passed", result.output) + + +class TestDetectTool(unittest.TestCase): + """Tests for tool detection helpers.""" + + def test_detect_python(self): + """Should detect the currently running Python.""" + # python or python3 should be findable + path, name = _detect_tool(["python3", "python"]) + self.assertIsNotNone(path) + self.assertIn(name, ["python3", "python"]) + + def test_detect_nonexistent_tool(self): + """Should return None for a tool that doesn't exist.""" + path, name = _detect_tool(["nonexistent_tool_abc123"]) + self.assertIsNone(path) + self.assertIsNone(name) + + def test_detect_tool_tries_multiple_names(self): + """Should try all candidate names and return the first match.""" + path, name = _detect_tool(["nonexistent_tool_abc123", "python3", "python"]) + self.assertIsNotNone(path) + + def test_detect_tool_empty_list(self): + """Should handle an empty candidate list gracefully.""" + path, name = _detect_tool([]) + self.assertIsNone(path) + self.assertIsNone(name) + + +class TestGetPlatformKey(unittest.TestCase): + """Tests for platform detection.""" + + def test_returns_valid_key(self): + """Should return 'posix' or 'windows'.""" + key = _get_platform_key() + self.assertIn(key, ["posix", "windows"]) + + @patch("concore_cli.commands.doctor.os.name", "nt") + def test_windows_detection(self): + """Should return 'windows' when os.name is 'nt'.""" + key = _get_platform_key() + self.assertEqual(key, "windows") + + @patch("concore_cli.commands.doctor.os.name", "posix") + def test_posix_detection(self): + """Should return 'posix' when os.name is 'posix'.""" + key = _get_platform_key() + self.assertEqual(key, "posix") + + +class TestCheckPackage(unittest.TestCase): + """Tests for package checking.""" + + def test_check_installed_package(self): + """Should detect an installed package.""" + found, version = _check_package("click") + self.assertTrue(found) + self.assertIsNotNone(version) + + def test_check_missing_package(self): + """Should return False for a package that isn't installed.""" + found, version = _check_package("nonexistent_package_abc123") + self.assertFalse(found) + self.assertIsNone(version) + + def test_check_package_with_import_name_map(self): + """Should use the correct import name for beautifulsoup4 (bs4).""" + found, version = _check_package("beautifulsoup4") + self.assertTrue(found) + + def test_check_pyzmq_import_name(self): + """Should use 'zmq' as import name for pyzmq.""" + found, version = _check_package("pyzmq") + self.assertTrue(found) + + +class TestResolveConCorePath(unittest.TestCase): + """Tests for CONCOREPATH resolution.""" + + def test_resolves_to_existing_path(self): + """Should return a Path object.""" + result = _resolve_concore_path() + self.assertIsInstance(result, Path) + + +class TestDoctorWithConfig(unittest.TestCase): + """Tests for doctor command with config files present.""" + + def setUp(self): + self.runner = CliRunner() + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.temp_dir) + + @patch("concore_cli.commands.doctor._resolve_concore_path") + def test_doctor_with_concore_tools(self, mock_path): + """Doctor should detect and report concore.tools.""" + mock_path.return_value = Path(self.temp_dir) + tools_file = Path(self.temp_dir) / "concore.tools" + tools_file.write_text("CPPEXE=/usr/bin/g++\nPYTHONEXE=/usr/bin/python3\n") + + from rich.console import Console + import io + + console = Console(file=io.StringIO(), force_terminal=True) + result = doctor_check(console) + # Just verify it doesn't crash + self.assertIsInstance(result, bool) + + @patch("concore_cli.commands.doctor._resolve_concore_path") + def test_doctor_with_concore_octave(self, mock_path): + """Doctor should detect concore.octave flag.""" + mock_path.return_value = Path(self.temp_dir) + octave_file = Path(self.temp_dir) / "concore.octave" + octave_file.write_text("") + + from rich.console import Console + import io + + console = Console(file=io.StringIO(), force_terminal=True) + result = doctor_check(console) + self.assertIsInstance(result, bool) + + @patch("concore_cli.commands.doctor._resolve_concore_path") + def test_doctor_with_concore_sudo(self, mock_path): + """Doctor should detect concore.sudo config.""" + mock_path.return_value = Path(self.temp_dir) + sudo_file = Path(self.temp_dir) / "concore.sudo" + sudo_file.write_text("docker") + + from rich.console import Console + import io + + console = Console(file=io.StringIO(), force_terminal=True) + result = doctor_check(console) + self.assertIsInstance(result, bool) + + +if __name__ == "__main__": + unittest.main()