From ae39be00c6f6000638c0996dabf80e8b8221e3c9 Mon Sep 17 00:00:00 2001 From: Ganesh Patil <7030871503ganeshpatil@gmail.com> Date: Mon, 9 Mar 2026 08:49:25 +0530 Subject: [PATCH 1/4] feat: add concore doctor command for system readiness checks Implements a new 'concore doctor' CLI command that performs comprehensive system readiness diagnostics including: - Core checks: Python version, concore installation, CONCOREPATH - Tool detection: C++, Python, Verilog, Octave, MATLAB, Docker - Configuration file validation (concore.tools, .octave, .mcr, .sudo) - Environment variable detection for tool overrides - Dependency checks for required and optional packages Includes 23 unit tests covering all diagnostic functions. Closes #495 --- concore_cli/cli.py | 13 + concore_cli/commands/__init__.py | 2 + concore_cli/commands/doctor.py | 428 +++++++++++++++++++++++++++++++ tests/test_doctor.py | 222 ++++++++++++++++ 4 files changed, 665 insertions(+) create mode 100644 concore_cli/commands/doctor.py create mode 100644 tests/test_doctor.py 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..98ea356 --- /dev/null +++ b/concore_cli/commands/doctor.py @@ -0,0 +1,428 @@ +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]", +} + +# Map import names that differ from package names +IMPORT_NAME_MAP = { + "beautifulsoup4": "bs4", + "pyzmq": "zmq", +} + + +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: + result = subprocess.run( + [path, version_flag], + 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.""" + import_name = IMPORT_NAME_MAP.get(package_name, package_name) + try: + mod = __import__(import_name) + version = getattr(mod, "__version__", None) + if version is None: + version = getattr(mod, "VERSION", "installed") + return True, str(version) + except ImportError: + 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() + py_major, py_minor = sys.version_info.major, sys.version_info.minor + if py_major >= 3 and py_minor >= 9: + console.print(f" [green]✓[/green] Python {py_version} (>= 3.9 required)") + passed += 1 + else: + console.print( + f" [red]✗[/red] Python {py_version} — " + f"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]✗[/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]✗[/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 "" + 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}{version_str} " + f"→ {path}{extra}" + ) + continue + console.print( + f" [green]✓[/green] {tool_label}{version_str} → {path}{extra}" + ) + passed += 1 + else: + hint = tool_def["install_hints"].get(plat_name, "") + hint_str = f" (install: {hint})" if hint else "" + # MATLAB is optional if Octave is available, show as warning + if tool_label == "MATLAB": + console.print( + f" [yellow]![/yellow] {tool_label} → Not found{hint_str}" + ) + warnings += 1 + elif tool_label == "Verilog (iverilog)": + console.print( + f" [yellow]![/yellow] {tool_label} → Not found{hint_str}" + ) + warnings += 1 + else: + console.print( + f" [red]✗[/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.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}" + ) + 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}" + ) + 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})" + ) + + # Check environment variables + env_vars = [ + "CONCORE_CPPEXE", "CONCORE_PYTHONEXE", "CONCORE_VEXE", + "CONCORE_OCTAVEEXE", "CONCORE_MATLABEXE", "DOCKEREXE", + ] + env_set = [v for v in env_vars if os.environ.get(v)] + if env_set: + console.print( + f" [green]✓[/green] Environment variables: " + f"{', '.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]✗[/red] {pkg} → Not installed " + f"(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 " + f"({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]") + + console.print(f"[bold]Summary:[/bold] {', '.join(summary_parts)}") + + 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..b1b66a5 --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,222 @@ +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.""" + result = self.runner.invoke(cli, ["doctor"]) + self.assertIn("concore", result.output) + self.assertIn("1.0.0", 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("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("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) + + def test_resolved_path_contains_concore(self): + """Resolved path should contain concore.py if run from repo.""" + result = _resolve_concore_path() + # In the test environment (run from repo), concore.py should exist + # This may not hold in all CI environments, so we just check it's a 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() From a87437f3b9ca3dc9fb9e84b6ab0d614bc50eb3ce Mon Sep 17 00:00:00 2001 From: Ganesh Patil <7030871503ganeshpatil@gmail.com> Date: Mon, 9 Mar 2026 12:28:46 +0530 Subject: [PATCH 2/4] refactor: apply review suggestions to concore doctor - Use importlib.metadata instead of __import__ for package version checks - Fix Python version check to use tuple comparison (sys.version_info >= (3, 9)) - Make MATLAB version_flag a list to handle multi-arg flags correctly - Support list-type version_flag in _get_version() - Show found executable name (e.g., [g++]) in tool output - Treat Docker-not-found as warning instead of error (optional tool) - Add concore.repo to config file checks - Fix concore.mcr passed counter not incrementing on valid path - Build env var list dynamically from TOOL_DEFINITIONS config_keys - Handle empty summary_parts gracefully - Remove unused IMPORT_NAME_MAP (replaced by importlib.metadata) - Remove duplicate test (test_resolved_path_contains_concore) - Patch os.name via correct module path in platform key tests - Use __version__ import instead of hardcoded version in test --- concore_cli/commands/doctor.py | 72 +++++++++++++++++----------------- tests/test_doctor.py | 14 ++----- 2 files changed, 41 insertions(+), 45 deletions(-) diff --git a/concore_cli/commands/doctor.py b/concore_cli/commands/doctor.py index 98ea356..67b0792 100644 --- a/concore_cli/commands/doctor.py +++ b/concore_cli/commands/doctor.py @@ -1,3 +1,4 @@ +import importlib.metadata import shutil import subprocess import sys @@ -65,7 +66,7 @@ "posix": ["matlab"], "windows": ["matlab"], }, - "version_flag": "-batch \"disp('ok')\"", + "version_flag": ["-batch", "disp('ok')"], "config_keys": ["MATLABEXE", "MATLABWIN"], "install_hints": { "Linux": "Install from https://mathworks.com/downloads/", @@ -103,13 +104,6 @@ "matplotlib": "pip install concore[demo]", } -# Map import names that differ from package names -IMPORT_NAME_MAP = { - "beautifulsoup4": "bs4", - "pyzmq": "zmq", -} - - def _get_platform_key(): """Return 'posix' or 'windows' based on OS.""" return "windows" if os.name == "nt" else "posix" @@ -146,8 +140,12 @@ def _detect_tool(names): 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( - [path, version_flag], + cmd, capture_output=True, text=True, timeout=10, @@ -176,14 +174,10 @@ def _check_docker_daemon(docker_path): def _check_package(package_name): """Check if a Python package is importable and get its version.""" - import_name = IMPORT_NAME_MAP.get(package_name, package_name) try: - mod = __import__(import_name) - version = getattr(mod, "__version__", None) - if version is None: - version = getattr(mod, "VERSION", "installed") - return True, str(version) - except ImportError: + version = importlib.metadata.version(package_name) + return True, version + except importlib.metadata.PackageNotFoundError: return False, None @@ -207,8 +201,7 @@ def doctor_check(console): # Python version py_version = platform.python_version() - py_major, py_minor = sys.version_info.major, sys.version_info.minor - if py_major >= 3 and py_minor >= 9: + if sys.version_info >= (3, 9): console.print(f" [green]✓[/green] Python {py_version} (>= 3.9 required)") passed += 1 else: @@ -261,6 +254,7 @@ def doctor_check(console): 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) @@ -272,24 +266,20 @@ def doctor_check(console): if not daemon_ok: warnings += 1 console.print( - f" [yellow]![/yellow] {tool_label}{version_str} " - f"→ {path}{extra}" + f" [yellow]![/yellow] {tool_label}{exe_info}" + f"{version_str} → {path}{extra}" ) continue console.print( - f" [green]✓[/green] {tool_label}{version_str} → {path}{extra}" + 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 "" - # MATLAB is optional if Octave is available, show as warning - if tool_label == "MATLAB": - console.print( - f" [yellow]![/yellow] {tool_label} → Not found{hint_str}" - ) - warnings += 1 - elif tool_label == "Verilog (iverilog)": + # 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}" ) @@ -309,6 +299,7 @@ def doctor_check(console): "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", } @@ -331,17 +322,22 @@ def doctor_check(console): 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 + 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" @@ -357,11 +353,12 @@ def doctor_check(console): f" [dim]—[/dim] {filename} → Not set ({description})" ) - # Check environment variables - env_vars = [ - "CONCORE_CPPEXE", "CONCORE_PYTHONEXE", "CONCORE_VEXE", - "CONCORE_OCTAVEEXE", "CONCORE_MATLABEXE", "DOCKEREXE", - ] + # 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( @@ -412,7 +409,12 @@ def doctor_check(console): if errors: summary_parts.append(f"[red]{errors} error(s)[/red]") - console.print(f"[bold]Summary:[/bold] {', '.join(summary_parts)}") + 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() diff --git a/tests/test_doctor.py b/tests/test_doctor.py index b1b66a5..6722ae5 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -45,9 +45,10 @@ def test_doctor_shows_python_version(self): 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("1.0.0", result.output) + self.assertIn(__version__, result.output) def test_doctor_shows_concorepath(self): """Doctor should show the CONCOREPATH.""" @@ -106,13 +107,13 @@ def test_returns_valid_key(self): key = _get_platform_key() self.assertIn(key, ["posix", "windows"]) - @patch("os.name", "nt") + @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("os.name", "posix") + @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() @@ -153,13 +154,6 @@ def test_resolves_to_existing_path(self): result = _resolve_concore_path() self.assertIsInstance(result, Path) - def test_resolved_path_contains_concore(self): - """Resolved path should contain concore.py if run from repo.""" - result = _resolve_concore_path() - # In the test environment (run from repo), concore.py should exist - # This may not hold in all CI environments, so we just check it's a Path - self.assertIsInstance(result, Path) - class TestDoctorWithConfig(unittest.TestCase): """Tests for doctor command with config files present.""" From 243ab44805d9277823a0208337f67b5de893af4c Mon Sep 17 00:00:00 2001 From: Ganesh Patil <7030871503ganeshpatil@gmail.com> Date: Mon, 9 Mar 2026 12:32:04 +0530 Subject: [PATCH 3/4] style: apply ruff format to doctor.py and test_doctor.py --- concore_cli/commands/doctor.py | 57 ++++++++++++---------------------- tests/test_doctor.py | 21 ++++++------- 2 files changed, 28 insertions(+), 50 deletions(-) diff --git a/concore_cli/commands/doctor.py b/concore_cli/commands/doctor.py index 67b0792..0d5183d 100644 --- a/concore_cli/commands/doctor.py +++ b/concore_cli/commands/doctor.py @@ -104,6 +104,7 @@ "matplotlib": "pip install concore[demo]", } + def _get_platform_key(): """Return 'posix' or 'windows' based on OS.""" return "windows" if os.name == "nt" else "posix" @@ -206,14 +207,14 @@ def doctor_check(console): passed += 1 else: console.print( - f" [red]✗[/red] Python {py_version} — " - f"concore requires Python >= 3.9" + f" [red]✗[/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: @@ -226,9 +227,7 @@ def doctor_check(console): 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})" - ) + console.print(f" [green]✓[/green] CONCOREPATH: {concore_path} ({status})") passed += 1 else: console.print( @@ -285,9 +284,7 @@ def doctor_check(console): ) warnings += 1 else: - console.print( - f" [red]✗[/red] {tool_label} → Not found{hint_str}" - ) + console.print(f" [red]✗[/red] {tool_label} → Not found{hint_str}") errors += 1 console.print() @@ -310,8 +307,11 @@ def doctor_check(console): 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("#")] + [ + ln + for ln in content.splitlines() + if ln.strip() and not ln.strip().startswith("#") + ] ) console.print( f" [green]✓[/green] {filename} → " @@ -319,9 +319,7 @@ def doctor_check(console): ) elif filename == "concore.mcr": if os.path.exists(os.path.expanduser(content)): - console.print( - f" [green]✓[/green] {filename} → {content}" - ) + console.print(f" [green]✓[/green] {filename} → {content}") passed += 1 else: console.print( @@ -331,27 +329,17 @@ def doctor_check(console): warnings += 1 continue elif filename == "concore.sudo": - console.print( - f" [green]✓[/green] {filename} → {content}" - ) + console.print(f" [green]✓[/green] {filename} → {content}") elif filename == "concore.repo": - console.print( - f" [green]✓[/green] {filename} → {content}" - ) + console.print(f" [green]✓[/green] {filename} → {content}") else: - console.print( - f" [green]✓[/green] {filename} → Enabled" - ) + console.print(f" [green]✓[/green] {filename} → Enabled") passed += 1 except Exception: - console.print( - f" [yellow]![/yellow] {filename} → Could not read" - ) + console.print(f" [yellow]![/yellow] {filename} → Could not read") warnings += 1 else: - console.print( - f" [dim]—[/dim] {filename} → Not set ({description})" - ) + console.print(f" [dim]—[/dim] {filename} → Not set ({description})") # Build environment variable list from TOOL_DEFINITIONS config_keys env_vars = [] @@ -361,10 +349,7 @@ def doctor_check(console): 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: " - f"{', '.join(env_set)}" - ) + console.print(f" [green]✓[/green] Environment variables: {', '.join(env_set)}") passed += 1 else: console.print(" [dim]—[/dim] No concore environment variables set") @@ -380,10 +365,7 @@ def doctor_check(console): console.print(f" [green]✓[/green] {pkg} {version}") passed += 1 else: - console.print( - f" [red]✗[/red] {pkg} → Not installed " - f"(pip install {pkg})" - ) + console.print(f" [red]✗[/red] {pkg} → Not installed (pip install {pkg})") errors += 1 for pkg, install_hint in OPTIONAL_PACKAGES.items(): @@ -393,8 +375,7 @@ def doctor_check(console): passed += 1 else: console.print( - f" [yellow]![/yellow] {pkg} → Not installed " - f"({install_hint})" + f" [yellow]![/yellow] {pkg} → Not installed ({install_hint})" ) warnings += 1 diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 6722ae5..e9ec584 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -40,12 +40,14 @@ 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) @@ -87,9 +89,7 @@ def test_detect_nonexistent_tool(self): 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"] - ) + path, name = _detect_tool(["nonexistent_tool_abc123", "python3", "python"]) self.assertIsNotNone(path) def test_detect_tool_empty_list(self): @@ -165,9 +165,7 @@ def setUp(self): def tearDown(self): shutil.rmtree(self.temp_dir) - @patch( - "concore_cli.commands.doctor._resolve_concore_path" - ) + @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) @@ -176,14 +174,13 @@ def test_doctor_with_concore_tools(self, mock_path): 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" - ) + @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) @@ -192,13 +189,12 @@ def test_doctor_with_concore_octave(self, mock_path): 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" - ) + @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) @@ -207,6 +203,7 @@ def test_doctor_with_concore_sudo(self, mock_path): from rich.console import Console import io + console = Console(file=io.StringIO(), force_terminal=True) result = doctor_check(console) self.assertIsInstance(result, bool) From 888f143cadbcdbe2e6d88942225c2f1dffdda37d Mon Sep 17 00:00:00 2001 From: Ganesh Patil <7030871503ganeshpatil@gmail.com> Date: Mon, 9 Mar 2026 12:57:09 +0530 Subject: [PATCH 4/4] fix: replace Unicode symbols with ASCII for Windows cp1252 compatibility Replace non-ASCII characters (U+2713, U+2717, U+2192, U+2014) with ASCII equivalents (+, x, ->, -) to prevent UnicodeEncodeError on Windows terminals using legacy cp1252 encoding. Rich color markup still conveys pass/fail/warn semantics via green/red/yellow styling. --- concore_cli/commands/doctor.py | 54 +++++++++++++++++----------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/concore_cli/commands/doctor.py b/concore_cli/commands/doctor.py index 0d5183d..254edfd 100644 --- a/concore_cli/commands/doctor.py +++ b/concore_cli/commands/doctor.py @@ -191,7 +191,7 @@ def doctor_check(console): console.print() console.print( Panel.fit( - "[bold]concore Doctor — System Readiness Report[/bold]", + "[bold]concore Doctor - System Readiness Report[/bold]", border_style="cyan", ) ) @@ -203,11 +203,11 @@ def doctor_check(console): # Python version py_version = platform.python_version() if sys.version_info >= (3, 9): - console.print(f" [green]✓[/green] Python {py_version} (>= 3.9 required)") + console.print(f" [green]+[/green] Python {py_version} (>= 3.9 required)") passed += 1 else: console.print( - f" [red]✗[/red] Python {py_version} — concore requires Python >= 3.9" + f" [red]x[/red] Python {py_version} - concore requires Python >= 3.9" ) errors += 1 @@ -215,10 +215,10 @@ def doctor_check(console): try: from concore_cli import __version__ - console.print(f" [green]✓[/green] concore {__version__} installed") + console.print(f" [green]+[/green] concore {__version__} installed") passed += 1 except ImportError: - console.print(" [red]✗[/red] concore package not found") + console.print(" [red]x[/red] concore package not found") errors += 1 # CONCOREPATH @@ -227,7 +227,7 @@ def doctor_check(console): 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})") + console.print(f" [green]+[/green] CONCOREPATH: {concore_path} ({status})") passed += 1 else: console.print( @@ -235,7 +235,7 @@ def doctor_check(console): ) warnings += 1 else: - console.print(f" [red]✗[/red] CONCOREPATH: {concore_path} (not found)") + console.print(f" [red]x[/red] CONCOREPATH: {concore_path} (not found)") errors += 1 console.print() @@ -266,25 +266,25 @@ def doctor_check(console): warnings += 1 console.print( f" [yellow]![/yellow] {tool_label}{exe_info}" - f"{version_str} → {path}{extra}" + f"{version_str} -> {path}{extra}" ) continue console.print( - f" [green]✓[/green] {tool_label}{exe_info}" - f"{version_str} → {path}{extra}" + 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 + # 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}" + f" [yellow]![/yellow] {tool_label} -> Not found{hint_str}" ) warnings += 1 else: - console.print(f" [red]✗[/red] {tool_label} → Not found{hint_str}") + console.print(f" [red]x[/red] {tool_label} -> Not found{hint_str}") errors += 1 console.print() @@ -314,32 +314,32 @@ def doctor_check(console): ] ) console.print( - f" [green]✓[/green] {filename} → " + 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}") + console.print(f" [green]+[/green] {filename} -> {content}") passed += 1 else: console.print( - f" [yellow]![/yellow] {filename} → " + f" [yellow]![/yellow] {filename} -> " f"path does not exist: {content}" ) warnings += 1 continue elif filename == "concore.sudo": - console.print(f" [green]✓[/green] {filename} → {content}") + console.print(f" [green]+[/green] {filename} -> {content}") elif filename == "concore.repo": - console.print(f" [green]✓[/green] {filename} → {content}") + console.print(f" [green]+[/green] {filename} -> {content}") else: - console.print(f" [green]✓[/green] {filename} → Enabled") + console.print(f" [green]+[/green] {filename} -> Enabled") passed += 1 except Exception: - console.print(f" [yellow]![/yellow] {filename} → Could not read") + console.print(f" [yellow]![/yellow] {filename} -> Could not read") warnings += 1 else: - console.print(f" [dim]—[/dim] {filename} → Not set ({description})") + console.print(f" [dim]-[/dim] {filename} -> Not set ({description})") # Build environment variable list from TOOL_DEFINITIONS config_keys env_vars = [] @@ -349,10 +349,10 @@ def doctor_check(console): 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)}") + console.print(f" [green]+[/green] Environment variables: {', '.join(env_set)}") passed += 1 else: - console.print(" [dim]—[/dim] No concore environment variables set") + console.print(" [dim]-[/dim] No concore environment variables set") console.print() @@ -362,20 +362,20 @@ def doctor_check(console): for pkg in REQUIRED_PACKAGES: found, version = _check_package(pkg) if found: - console.print(f" [green]✓[/green] {pkg} {version}") + console.print(f" [green]+[/green] {pkg} {version}") passed += 1 else: - console.print(f" [red]✗[/red] {pkg} → Not installed (pip install {pkg})") + 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}") + console.print(f" [green]+[/green] {pkg} {version}") passed += 1 else: console.print( - f" [yellow]![/yellow] {pkg} → Not installed ({install_hint})" + f" [yellow]![/yellow] {pkg} -> Not installed ({install_hint})" ) warnings += 1