From 2120ac62f86d147042ff2716969b4a892251769b Mon Sep 17 00:00:00 2001 From: mitulgarg Date: Mon, 15 Jun 2026 19:01:59 +0530 Subject: [PATCH 1/2] feat: Jupyter HTML output, public check() API, and --format html flag (v0.3.4) - Auto-detects Jupyter/Colab kernels via is_notebook() and renders rich self-contained HTML inline; falls back to terminal text everywhere else - New public API: from env_doctor import check; check() returns CheckReport with _repr_html_(), __repr__(), .html, and .to_dict() - --format {text,html,json} CLI flag on env-doctor check; --json/--ci unchanged - report/ module: is_notebook(), format_result_html() with inline CSS, no JS - Logo SVG bundled at report/logo.svg; replaces emoji in HTML header - Extracted collect_check_results() and render_check_text() so all renderers share a single detection pass - Optional [notebook] extra: pip install env-doctor[notebook] - 22 new unit tests in tests/unit/test_html_report.py --- CHANGELOG.md | 16 ++ pyproject.toml | 7 +- src/env_doctor/__init__.py | 6 +- src/env_doctor/api.py | 78 ++++++ src/env_doctor/cli.py | 363 ++++++++++++++++----------- src/env_doctor/report/__init__.py | 9 + src/env_doctor/report/environment.py | 21 ++ src/env_doctor/report/html.py | 232 +++++++++++++++++ src/env_doctor/report/logo.svg | 7 + tests/unit/test_html_report.py | 168 +++++++++++++ 10 files changed, 758 insertions(+), 149 deletions(-) create mode 100644 src/env_doctor/api.py create mode 100644 src/env_doctor/report/__init__.py create mode 100644 src/env_doctor/report/environment.py create mode 100644 src/env_doctor/report/html.py create mode 100644 src/env_doctor/report/logo.svg create mode 100644 tests/unit/test_html_report.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c10dbc8..1d734de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to env-doctor will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.4] - 2026-06-15 + +### Added +- **Jupyter / notebook HTML output**: `from env_doctor import check; check()` auto-detects a live Jupyter kernel and renders a rich, self-contained HTML report inline. Falls back to the familiar terminal text report everywhere else. +- **`CheckReport` public API**: `check()` returns a `CheckReport` object with `_repr_html_()` (Jupyter display protocol), `__repr__()`, `.html` (raw HTML string), and `.to_dict()`. +- **`--format {text,html,json}` CLI flag**: `env-doctor check --format html` emits a self-contained HTML fragment for CI reports or `> report.html`. `--json` / `--ci` still work identically. +- **`report` module**: `env_doctor.report.is_notebook()` detects Jupyter/Colab kernels; `env_doctor.report.format_result_html(output)` produces inline-CSS HTML with no external deps. +- **`[notebook]` optional extra**: `pip install env-doctor[notebook]` pulls in IPython. Core deps unchanged — IPython stays optional and lazily imported. + +### Changed +- Extracted `collect_check_results()` and `render_check_text()` from `check_command()` so the JSON, HTML, text, and public API renderers share a single detection pass. +- Logo in HTML report now uses the project SVG (`src/env_doctor/report/logo.svg`) instead of an emoji. + +--- + ## [0.3.2] - 2026-05-02 ### Fixed @@ -301,6 +316,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Basic CUDA toolkit detection - Library installation commands +[0.3.4]: https://github.com/mitulgarg/env-doctor/compare/v0.3.3...v0.3.4 [0.3.1]: https://github.com/mitulgarg/env-doctor/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/mitulgarg/env-doctor/compare/v0.2.9...v0.3.0 [0.2.9]: https://github.com/mitulgarg/env-doctor/compare/v0.2.8...v0.2.9 diff --git a/pyproject.toml b/pyproject.toml index b5417ad..d49f0fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "env-doctor" -version = "0.3.3" +version = "0.3.4" description = "A CLI tool to verify and fix AI/ML environment compatibility (Driver <-> CUDA <-> Wheels) with platform-specific installation guides." readme = "README.md" license = { file = "LICENSE" } @@ -39,6 +39,9 @@ dashboard = [ "sqlalchemy>=2.0.0,<3.0.0", "aiosqlite>=0.17.0,<1.0.0", ] +notebook = [ + "ipython>=7.0", +] [project.scripts] env-doctor = "env_doctor.cli:main" @@ -57,7 +60,7 @@ package-dir = {"" = "src"} where = ["src"] [tool.setuptools.package-data] -"*" = ["data/*.json", "web/**/*"] +"*" = ["data/*.json", "web/**/*", "report/*.svg"] [dependency-groups] dev = ["pytest"] diff --git a/src/env_doctor/__init__.py b/src/env_doctor/__init__.py index 26eaa49..1c5958d 100644 --- a/src/env_doctor/__init__.py +++ b/src/env_doctor/__init__.py @@ -1 +1,5 @@ -__version__ = "0.3.3" \ No newline at end of file +__version__ = "0.3.4" + +from .api import check, CheckReport + +__all__ = ["check", "CheckReport", "__version__"] diff --git a/src/env_doctor/api.py b/src/env_doctor/api.py new file mode 100644 index 0000000..a0d9c59 --- /dev/null +++ b/src/env_doctor/api.py @@ -0,0 +1,78 @@ +"""Public Python API for env-doctor, designed for notebook use. + +Typical usage inside a Jupyter notebook:: + + from env_doctor import check + check() # last cell expression -> rich HTML report + +In a plain terminal / script the same call prints the familiar text report. +""" +from __future__ import annotations + +from typing import Any, Dict, Optional + +from .report import is_notebook, format_result_html + + +class CheckReport: + """Result of :func:`check`, with format-aware display hooks. + + - In Jupyter, being the last expression in a cell triggers + ``_repr_html_`` and renders the rich HTML report. + - In an interactive terminal, ``__repr__`` shows a one-line summary + (the full text report is printed by :func:`check` itself). + - ``.html`` and ``.to_dict()`` expose the report for programmatic use. + """ + + def __init__(self, output: Dict[str, Any]) -> None: + self._output = output + + def to_dict(self) -> Dict[str, Any]: + """Return the underlying structured result dict.""" + return self._output + + @property + def html(self) -> str: + """Return the self-contained HTML report as a string.""" + return format_result_html(self._output) + + def _repr_html_(self) -> str: # noqa: D401 - IPython display protocol + return self.html + + def __repr__(self) -> str: + summary = self._output.get("summary", {}) + return ( + f"" + ) + + +def check(format: Optional[str] = None) -> CheckReport: + """Run the environment check and render it for the current environment. + + In a notebook, return the result as a cell's last expression and Jupyter + renders the rich HTML automatically (via ``_repr_html_``) — no explicit + ``display()`` call, so it never double-renders. In a terminal/script the + familiar text report is printed. + + Args: + format: Force a renderer regardless of environment: ``"text"`` always + prints the terminal report; ``"html"`` skips the text print and + leaves rendering to the returned object's ``_repr_html_`` (use + ``check(format="html").html`` for the raw string). ``None`` + (default) auto-detects: HTML in a notebook, text in a terminal. + + Returns: + A :class:`CheckReport`. + """ + # Imported here to avoid a circular import (cli imports report, api). + from .cli import collect_check_results, render_check_text + + bundle = collect_check_results() + report = CheckReport(bundle["output"]) + + render_html = format == "html" or (format is None and is_notebook()) + if not render_html: + render_check_text(bundle) + + return report \ No newline at end of file diff --git a/src/env_doctor/cli.py b/src/env_doctor/cli.py index d15920c..5d2ad57 100644 --- a/src/env_doctor/cli.py +++ b/src/env_doctor/cli.py @@ -429,23 +429,18 @@ def _build_check_output(results, driver_result, cuda_result, cudnn_result, } -def check_command(output_json: bool = False, ci: bool = False, - report_to: str = None, force_report: bool = False, - token: str = None): - """ - Main diagnostic command using detector architecture. +def collect_check_results() -> Dict[str, Any]: + """Run every detector once and return a bundle of results. - This is the MODERNIZED version that uses DetectorRegistry - instead of direct function calls. + Centralizes detection so the human, JSON, HTML, and dashboard-report + renderers all consume the exact same data without re-running detectors. - Args: - output_json: Output as JSON (machine-readable) - ci: CI-friendly mode (implies JSON + proper exit codes) - report_to: Dashboard URL to POST results to - force_report: Bypass change detection (always send) - token: Bearer token for the dashboard API (overrides env var/config) + Returns: + dict with the raw DetectionResult objects, the ``results`` map used + for status/exit-code logic, the computed compute-capability info, + torch's ``cuda_available`` flag, and the structured ``output`` dict + produced by ``_build_check_output``. """ - # === Collect all detection results === # STEP 1: Environment Detection wsl2_detector = DetectorRegistry.get("wsl2") wsl2_result = wsl2_detector.detect() if wsl2_detector.can_run() else None @@ -482,7 +477,7 @@ def check_command(output_json: bool = False, ci: bool = False, python_compat_detector = DetectorRegistry.get("python_compat") python_compat_result = python_compat_detector.detect() - # Organize results for JSON output + # Organize results for status/exit-code logic results = { "wsl2": wsl2_result, "driver": driver_result, @@ -492,49 +487,95 @@ def check_command(output_json: bool = False, ci: bool = False, "python_compat": python_compat_result, } - # === Choose output format === - # Compute capability check (for JSON, CI, and --report-to modes) + # Compute capability check (pure computation, no printing) compute_compat_info = None - need_structured = ci or output_json or report_to + cuda_available = None if torch_result and torch_result.detected and driver_result.detected: - if need_structured: - from .detectors.compute_capability import ( - get_sm_for_compute_capability, - get_arch_name, - is_sm_in_arch_list, - ) - gpu_cc = driver_result.metadata.get("primary_gpu_compute_capability") - arch_list = torch_result.metadata.get("arch_list", []) - gpu_name = driver_result.metadata.get("primary_gpu_name", "Unknown GPU") - torch_cuda = torch_result.metadata.get("cuda_version", "Unknown") - cuda_available_json = _get_torch_cuda_available() - - compute_compat_info = { - "gpu_name": gpu_name, - "compute_capability": gpu_cc, - "arch_list": arch_list, - "cuda_available": cuda_available_json, - } + from .detectors.compute_capability import ( + get_sm_for_compute_capability, + get_arch_name, + is_sm_in_arch_list, + ) + gpu_cc = driver_result.metadata.get("primary_gpu_compute_capability") + arch_list = torch_result.metadata.get("arch_list", []) + gpu_name = driver_result.metadata.get("primary_gpu_name", "Unknown GPU") + torch_cuda = torch_result.metadata.get("cuda_version", "Unknown") + cuda_available = _get_torch_cuda_available() - if gpu_cc and arch_list: - sm = get_sm_for_compute_capability(gpu_cc) - arch_name_val = get_arch_name(gpu_cc) - compute_compat_info["sm"] = sm - compute_compat_info["arch_name"] = arch_name_val - compatible = is_sm_in_arch_list(sm, arch_list) - compute_compat_info["status"] = "compatible" if compatible else "mismatch" - if not compatible and torch_cuda and torch_cuda != "Unknown": - cuda_slug = "cu" + torch_cuda.replace(".", "").split()[0] - compute_compat_info["nightly_url"] = f"https://download.pytorch.org/whl/nightly/{cuda_slug}" - else: - compute_compat_info["status"] = "unknown" + compute_compat_info = { + "gpu_name": gpu_name, + "compute_capability": gpu_cc, + "arch_list": arch_list, + "cuda_available": cuda_available, + } - # Build the output dict (needed for JSON, CI, and --report-to) - if need_structured: - output = _build_check_output( - results, driver_result, cuda_result, cudnn_result, - lib_results, python_compat_result, compute_compat_info - ) + if gpu_cc and arch_list: + sm = get_sm_for_compute_capability(gpu_cc) + arch_name_val = get_arch_name(gpu_cc) + compute_compat_info["sm"] = sm + compute_compat_info["arch_name"] = arch_name_val + compatible = is_sm_in_arch_list(sm, arch_list) + compute_compat_info["status"] = "compatible" if compatible else "mismatch" + if not compatible and torch_cuda and torch_cuda != "Unknown": + cuda_slug = "cu" + torch_cuda.replace(".", "").split()[0] + compute_compat_info["nightly_url"] = f"https://download.pytorch.org/whl/nightly/{cuda_slug}" + else: + compute_compat_info["status"] = "unknown" + + output = _build_check_output( + results, driver_result, cuda_result, cudnn_result, + lib_results, python_compat_result, compute_compat_info + ) + + return { + "wsl2_result": wsl2_result, + "driver_result": driver_result, + "cuda_result": cuda_result, + "cudnn_result": cudnn_result, + "lib_results": lib_results, + "torch_result": torch_result, + "python_compat_result": python_compat_result, + "max_cuda": max_cuda, + "compute_compat_info": compute_compat_info, + "cuda_available": cuda_available, + "results": results, + "output": output, + } + + +def check_command(output_json: bool = False, ci: bool = False, + report_to: str = None, force_report: bool = False, + token: str = None, output_format: str = None): + """ + Main diagnostic command using detector architecture. + + This is the MODERNIZED version that uses DetectorRegistry + instead of direct function calls. + + Args: + output_json: Output as JSON (machine-readable) + ci: CI-friendly mode (implies JSON + proper exit codes) + report_to: Dashboard URL to POST results to + force_report: Bypass change detection (always send) + token: Bearer token for the dashboard API (overrides env var/config) + output_format: Explicit renderer: "text", "json", or "html". + Takes precedence over output_json/ci when set. + """ + # Normalize the requested format. --json/--ci stay backward compatible. + fmt = output_format or ("json" if (ci or output_json) else "text") + + # === Collect all detection results === + bundle = collect_check_results() + wsl2_result = bundle["wsl2_result"] + driver_result = bundle["driver_result"] + cuda_result = bundle["cuda_result"] + cudnn_result = bundle["cudnn_result"] + lib_results = bundle["lib_results"] + torch_result = bundle["torch_result"] + python_compat_result = bundle["python_compat_result"] + max_cuda = bundle["max_cuda"] + results = bundle["results"] + output = bundle["output"] # Smart reporting to dashboard if report_to: @@ -580,7 +621,7 @@ def check_command(output_json: bool = False, ci: bool = False, return resp.raise_for_status() mark_reported(output, url, is_heartbeat=is_heartbeat) - if not (ci or output_json): + if fmt == "text": kind = "heartbeat" if is_heartbeat else "report" print(f"✅ Sent {kind} to {url} (HTTP {resp.status_code})", file=sys.stderr) @@ -641,112 +682,135 @@ def check_command(output_json: bool = False, ci: bool = False, except Exception as e: print(f"⚠️ Failed to report to dashboard: {e}", file=sys.stderr) - if ci or output_json: + if fmt == "json": print(json.dumps(output, indent=2)) sys.exit(determine_exit_code(results)) + elif fmt == "html": + from .report import format_result_html + print(format_result_html(output)) + sys.exit(determine_exit_code(results)) else: - # Human output (existing code) - print("\n🩺 ENV-DOCTOR DIAGNOSIS 🩺") - print("==============================") - - # --- Show DB Status --- - meta = DB_DATA.get("_metadata", {}) - if meta: - print(f"🛡️ DB Verified: {meta.get('last_verified', 'Unknown')}") - print(f" Method: {meta.get('method', 'Unknown')}") - print("------------------------------") - - # === STEP 1: Environment Detection === - if wsl2_result: - print_detection_result(wsl2_result, "🐧") - print("------------------------------") - - # === STEP 2: Hardware Detection === - if driver_result.detected: - print(f"✅ GPU Driver Found: {driver_result.version}") - print(f" → Max Supported CUDA: {max_cuda}") - print(f" → Detection Method: {driver_result.metadata.get('detection_method', 'unknown')}") - else: - print("⚠️ No NVIDIA Driver detected.") - for rec in driver_result.recommendations: - print(f" → {rec}") - - # === STEP 3: System CUDA Detection === - if cuda_result.detected: - print(f"✅ System CUDA (nvcc): {cuda_result.version}") - if cuda_result.path: - print(f" Path: {cuda_result.path}") - - # Show quick status - install_count = cuda_result.metadata.get("installation_count", 1) - if install_count > 1: - print(f" ⚠️ {install_count} CUDA installations detected") - - if cuda_result.status == Status.WARNING: - print(f" ⚠️ Configuration issues detected (run 'doctor debug' for details)") - elif cuda_result.status == Status.ERROR: - print(f" ❌ Critical issues detected (run 'doctor debug' for details)") - else: - print("ℹ️ System CUDA (nvcc) not found.") - if cuda_result.recommendations: - print(f" → {cuda_result.recommendations[0]}") + # Human-readable terminal output + render_check_text(bundle) + + +def render_check_text(bundle: Dict[str, Any]) -> None: + """Print the human-readable terminal diagnosis from a results bundle. + Single source of truth for the ``check`` command's text output, shared by + the CLI and the public ``env_doctor.check()`` API (non-notebook path). + """ + wsl2_result = bundle["wsl2_result"] + driver_result = bundle["driver_result"] + cuda_result = bundle["cuda_result"] + cudnn_result = bundle["cudnn_result"] + lib_results = bundle["lib_results"] + torch_result = bundle["torch_result"] + python_compat_result = bundle["python_compat_result"] + max_cuda = bundle["max_cuda"] + + print("\n🩺 ENV-DOCTOR DIAGNOSIS 🩺") + print("==============================") + + # --- Show DB Status --- + meta = DB_DATA.get("_metadata", {}) + if meta: + print(f"🛡️ DB Verified: {meta.get('last_verified', 'Unknown')}") + print(f" Method: {meta.get('method', 'Unknown')}") print("------------------------------") - # cuDNN Detection - if cudnn_result and cudnn_result.detected: - print(f"✅ cuDNN: v{cudnn_result.version}") + # === STEP 1: Environment Detection === + if wsl2_result: + print_detection_result(wsl2_result, "🐧") + print("------------------------------") - # === STEP 4: Python Libraries Detection === - for lib, lib_result in lib_results.items(): - if lib_result.detected: - print(f"📦 Found {lib}: v{lib_result.version}") + # === STEP 2: Hardware Detection === + if driver_result.detected: + print(f"✅ GPU Driver Found: {driver_result.version}") + print(f" → Max Supported CUDA: {max_cuda}") + print(f" → Detection Method: {driver_result.metadata.get('detection_method', 'unknown')}") + else: + print("⚠️ No NVIDIA Driver detected.") + for rec in driver_result.recommendations: + print(f" → {rec}") - # Show bundled CUDA info - cuda_ver = lib_result.metadata.get("cuda_version", "Unknown") - if cuda_ver != "Unknown": - print(f" → Bundled CUDA: {cuda_ver}") + # === STEP 3: System CUDA Detection === + if cuda_result.detected: + print(f"✅ System CUDA (nvcc): {cuda_result.version}") + if cuda_result.path: + print(f" Path: {cuda_result.path}") + + # Show quick status + install_count = cuda_result.metadata.get("installation_count", 1) + if install_count > 1: + print(f" ⚠️ {install_count} CUDA installations detected") + + if cuda_result.status == Status.WARNING: + print(f" ⚠️ Configuration issues detected (run 'doctor debug' for details)") + elif cuda_result.status == Status.ERROR: + print(f" ❌ Critical issues detected (run 'doctor debug' for details)") + else: + print("ℹ️ System CUDA (nvcc) not found.") + if cuda_result.recommendations: + print(f" → {cuda_result.recommendations[0]}") - # Check compatibility with driver - if max_cuda: - check_library_compatibility(lib_result, max_cuda) - else: - print(f" → Bundled CUDA: Not Detected") - else: - print(f"❌ {lib} is NOT installed.") + print("------------------------------") - # === STEP 5: Python Compatibility Check === - print("------------------------------") - conflicts = python_compat_result.metadata.get("conflicts", []) - if python_compat_result.status == Status.SUCCESS: - checked = python_compat_result.metadata.get("constraints_checked", 0) - print(f"✅ Python {python_compat_result.version}: Compatible with all {checked} checked libraries") - elif python_compat_result.status == Status.ERROR: - print(f"❌ Python {python_compat_result.version}: {len(conflicts)} compatibility issue(s)") - for conflict in conflicts: - print(f" ⚠️ {conflict['message']}") - for rec in python_compat_result.recommendations: - print(f" → {rec}") + # cuDNN Detection + if cudnn_result and cudnn_result.detected: + print(f"✅ cuDNN: v{cudnn_result.version}") + + # === STEP 4: Python Libraries Detection === + for lib, lib_result in lib_results.items(): + if lib_result.detected: + print(f"📦 Found {lib}: v{lib_result.version}") + + # Show bundled CUDA info + cuda_ver = lib_result.metadata.get("cuda_version", "Unknown") + if cuda_ver != "Unknown": + print(f" → Bundled CUDA: {cuda_ver}") - # === STEP 6: Compilation Health Check === - if torch_result and torch_result.detected: - check_compilation_health(cuda_result, torch_result) + # Check compatibility with driver + if max_cuda: + check_library_compatibility(lib_result, max_cuda) + else: + print(f" → Bundled CUDA: Not Detected") + else: + print(f"❌ {lib} is NOT installed.") + + # === STEP 5: Python Compatibility Check === + print("------------------------------") + conflicts = python_compat_result.metadata.get("conflicts", []) + if python_compat_result.status == Status.SUCCESS: + checked = python_compat_result.metadata.get("constraints_checked", 0) + print(f"✅ Python {python_compat_result.version}: Compatible with all {checked} checked libraries") + elif python_compat_result.status == Status.ERROR: + print(f"❌ Python {python_compat_result.version}: {len(conflicts)} compatibility issue(s)") + for conflict in conflicts: + print(f" ⚠️ {conflict['message']}") + for rec in python_compat_result.recommendations: + print(f" → {rec}") - # === STEP 6b: Compute Capability Check === - if torch_result and torch_result.detected and driver_result.detected: - cuda_available = _get_torch_cuda_available() - check_compute_capability_compatibility(driver_result, torch_result, cuda_available) + # === STEP 6: Compilation Health Check === + if torch_result and torch_result.detected: + check_compilation_health(cuda_result, torch_result) - # === STEP 7: System Path Check === - check_system_path() + # === STEP 6b: Compute Capability Check === + if torch_result and torch_result.detected and driver_result.detected: + check_compute_capability_compatibility( + driver_result, torch_result, bundle["cuda_available"] + ) - # === STEP 7: Code Migration Check === - # (Not yet refactored - still using legacy function) - check_broken_imports() + # === STEP 7: System Path Check === + check_system_path() - # === STEP 8: Offer detailed analysis === - if cuda_result.detected and (cuda_result.issues or cuda_result.metadata.get("installation_count", 1) > 1): - print("\n💡 TIP: Run 'env-doctor cuda-info' for detailed CUDA analysis") + # === STEP 7: Code Migration Check === + # (Not yet refactored - still using legacy function) + check_broken_imports() + + # === STEP 8: Offer detailed analysis === + if cuda_result.detected and (cuda_result.issues or cuda_result.metadata.get("installation_count", 1) > 1): + print("\n💡 TIP: Run 'env-doctor cuda-info' for detailed CUDA analysis") @@ -2768,6 +2832,12 @@ def main(): action='store_true', help='CI-friendly mode (implies --json with proper exit codes)' ) + check_parser.add_argument( + '--format', + choices=['text', 'html', 'json'], + default=None, + help='Output format (overrides --json/--ci). html emits a self-contained report.' + ) check_parser.add_argument( '--report-to', metavar='URL', @@ -3062,6 +3132,7 @@ def main(): report_to=getattr(args, 'report_to', None), force_report=getattr(args, 'force', False), token=getattr(args, 'token', None), + output_format=getattr(args, 'format', None), ) elif args.command == "cuda-info": cuda_info_command( diff --git a/src/env_doctor/report/__init__.py b/src/env_doctor/report/__init__.py new file mode 100644 index 0000000..ac3de6e --- /dev/null +++ b/src/env_doctor/report/__init__.py @@ -0,0 +1,9 @@ +"""Rendering helpers for env-doctor check results. + +This package keeps presentation concerns (HTML rendering, runtime +environment detection) decoupled from the detection logic in ``cli.py``. +""" +from .environment import is_notebook +from .html import format_result_html + +__all__ = ["is_notebook", "format_result_html"] diff --git a/src/env_doctor/report/environment.py b/src/env_doctor/report/environment.py new file mode 100644 index 0000000..ed15552 --- /dev/null +++ b/src/env_doctor/report/environment.py @@ -0,0 +1,21 @@ +"""Runtime environment detection for choosing an output renderer.""" + + +def is_notebook() -> bool: + """Return True when running inside a Jupyter/IPython notebook kernel. + + Detects the ZMQ-based interactive shell used by Jupyter (notebook, + JupyterLab, VS Code, Colab). Returns False for plain terminals, the + classic IPython REPL, and when IPython is not installed at all. + + IPython is imported lazily so it never becomes a hard dependency. + """ + try: + from IPython import get_ipython + + shell = get_ipython().__class__.__name__ + except (ImportError, AttributeError): + return False + + # ZMQInteractiveShell -> Jupyter; Google Colab uses its own subclass name. + return shell in ("ZMQInteractiveShell", "Shell") diff --git a/src/env_doctor/report/html.py b/src/env_doctor/report/html.py new file mode 100644 index 0000000..d517fd3 --- /dev/null +++ b/src/env_doctor/report/html.py @@ -0,0 +1,232 @@ +"""Render an env-doctor check result as self-contained HTML. + +The input is the structured dict produced by ``cli._build_check_output``. +The output is a single HTML fragment with all styling inlined in a scoped +``
`` — no external stylesheets, no JavaScript — so it renders identically +in Jupyter, JupyterLab, VS Code, Colab, and saved ``.html`` files. +""" +from __future__ import annotations + +import html +from pathlib import Path +from typing import Any, Dict, Optional + +_LOGO_SVG = ( + Path(__file__).parent / "logo.svg" +).read_text(encoding="utf-8").strip().replace("\n", "").replace(" ", " ").replace( + " str: + return html.escape("" if value is None else str(value)) + + +def _badge(status: str) -> str: + color, label = _STATUS_COLORS.get(status, _STATUS_COLORS["unknown"]) + return ( + f'' + f"{_esc(label)}" + ) + + +def _fmt_value(value: Any) -> str: + """Render a metadata value as a compact, readable string.""" + if isinstance(value, dict): + return ", ".join(f"{_esc(k)}={_fmt_value(v)}" for k, v in value.items()) + if isinstance(value, (list, tuple)): + return ", ".join(_fmt_value(v) for v in value) if value else "—" + if isinstance(value, bool): + return "yes" if value else "no" + return _esc(value) + + +def _list_block(title: str, items, color: str) -> str: + if not items: + return "" + rows = "".join( + f'
  • {_esc(item)}
  • ' for item in items + ) + return ( + f'
    {_esc(title)}
    ' + f'
      {rows}
    ' + ) + + +def _metadata_block(metadata: Dict[str, Any]) -> str: + visible = {k: v for k, v in (metadata or {}).items() if k != "detection_method"} + if not visible: + return "" + rows = "".join( + f'{_esc(k.replace("_", " "))}' + f'{_fmt_value(v)}' + for k, v in visible.items() + ) + return ( + '
    Details' + f'' + f"{rows}
    " + ) + + +def _section(title: str, status: str, version: Optional[str], + path: Optional[str], metadata: Optional[dict], + issues, recommendations) -> str: + head_meta = "" + if version: + head_meta += ( + f'{_esc(version)}' + ) + if path: + head_meta += ( + f'
    ' + f"{_esc(path)}
    " + ) + return ( + f'
    ' + '
    ' + f'' + f"{_esc(title)}{_badge(status)}
    " + f'
    {head_meta}
    ' + f'{_list_block("Issues", issues, "#fca5a5")}' + f'{_list_block("Recommendations", recommendations, "#7dd3fc")}' + f"{_metadata_block(metadata)}" + "
    " + ) + + +def _render_check(title: str, check: Optional[dict]) -> str: + if not check: + return "" + return _section( + title=title, + status=check.get("status", "unknown"), + version=check.get("version"), + path=check.get("path"), + metadata=check.get("metadata"), + issues=check.get("issues"), + recommendations=check.get("recommendations"), + ) + + +def _render_compute_compat(info: Optional[dict]) -> str: + if not info: + return "" + status = info.get("status", "unknown") + gpu = info.get("gpu_name") + arch = info.get("arch_name") + sm = info.get("sm") + version = f"{gpu} ({arch} {sm})" if gpu and arch and sm else gpu + issues = [] + recs = [] + if info.get("message"): + (issues if status == "mismatch" else recs).append(info["message"]) + if info.get("nightly_url"): + recs.append(f"PyTorch nightly: {info['nightly_url']}") + return _section( + title="Compute Capability", + status=status, + version=version, + path=None, + metadata={"arch_list": info.get("arch_list"), + "cuda_available": info.get("cuda_available")}, + issues=issues, + recommendations=recs, + ) + + +def format_result_html(output: Dict[str, Any]) -> str: + """Build a self-contained HTML fragment from a check-output dict. + + Args: + output: The dict produced by ``cli._build_check_output`` (keys: + ``machine``, ``status``, ``timestamp``, ``summary``, ``checks``). + + Returns: + An HTML string safe to embed directly (all values are escaped). + """ + machine = output.get("machine", {}) + summary = output.get("summary", {}) + checks = output.get("checks", {}) + overall = output.get("status", "unknown") + + machine_line = " · ".join( + part for part in ( + _esc(machine.get("hostname")) if machine.get("hostname") else "", + _esc(machine.get("platform")) if machine.get("platform") else "", + f"Python {_esc(machine.get('python_version'))}" + if machine.get("python_version") else "", + ) if part + ) + + issues_count = summary.get("issues_count", 0) + issues_text = ( + f"{issues_count} issue{'s' if issues_count != 1 else ''} detected" + if issues_count else "No issues detected" + ) + + sections = [ + _render_check("WSL2 Environment", checks.get("wsl2")), + _render_check("NVIDIA Driver", checks.get("driver")), + _render_check("CUDA Toolkit", checks.get("cuda")), + _render_check("cuDNN", checks.get("cudnn")), + ] + for lib, lib_check in (checks.get("libraries") or {}).items(): + sections.append(_render_check(lib, lib_check)) + sections.append(_render_check("Python Compatibility", checks.get("python_compat"))) + sections.append(_render_compute_compat(checks.get("compute_compatibility"))) + body = "".join(s for s in sections if s) + + return ( + f'
    ' + '
    ' + '
    ' + f'{_LOGO_SVG}env-doctor
    ' + f'
    ' + f"{machine_line}
    " + f'
    {_badge(overall)}' + f'
    ' + f"{_esc(issues_text)}
    " + f"{body}" + f'
    {_esc(output.get("timestamp", ""))}
    ' + "
    " + ) diff --git a/src/env_doctor/report/logo.svg b/src/env_doctor/report/logo.svg new file mode 100644 index 0000000..4929d70 --- /dev/null +++ b/src/env_doctor/report/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/unit/test_html_report.py b/tests/unit/test_html_report.py new file mode 100644 index 0000000..0b23e66 --- /dev/null +++ b/tests/unit/test_html_report.py @@ -0,0 +1,168 @@ +"""Unit tests for notebook HTML rendering and the public check() API.""" +import importlib.util +from unittest.mock import patch + +import pytest + +_HAS_IPYTHON = importlib.util.find_spec("IPython") is not None + +from env_doctor.report import format_result_html, is_notebook +from env_doctor.report.html import _badge +from env_doctor.api import CheckReport + + +@pytest.fixture +def sample_output(): + """A representative structured check-output dict (cli._build_check_output shape).""" + return { + "machine": {"hostname": "box", "platform": "Linux", "python_version": "3.11.4"}, + "status": "warning", + "timestamp": "2026-06-15T00:00:00", + "summary": {"driver": "found", "cuda": "found", "cudnn": "not_found", "issues_count": 2}, + "checks": { + "wsl2": None, + "driver": { + "component": "nvidia_driver", "status": "success", "detected": True, + "version": "535.1", "path": None, + "metadata": {"detection_method": "pynvml", "gpu_count": 1}, + "issues": [], "recommendations": [], + }, + "cuda": { + "component": "cuda_toolkit", "status": "warning", "detected": True, + "version": "12.1", "path": "/usr/local/cuda/bin/nvcc", + "metadata": {"installation_count": 2}, + "issues": ["Multiple installs"], "recommendations": ["Set CUDA_HOME"], + }, + "cudnn": None, + "libraries": { + "torch": { + "component": "python_library_torch", "status": "success", + "detected": True, "version": "2.1.0+cu121", "path": None, + "metadata": {"cuda_version": "12.1"}, "issues": [], "recommendations": [], + }, + "tensorflow": { + "component": "tf", "status": "not_found", "detected": False, + "version": None, "path": None, "metadata": {}, + "issues": [], "recommendations": [], + }, + }, + "python_compat": { + "component": "python_compat", "status": "success", "detected": True, + "version": "3.11", "path": None, + "metadata": {"constraints_checked": 5}, "issues": [], "recommendations": [], + }, + "compute_compatibility": { + "gpu_name": "RTX 4090", "arch_name": "Ada", "sm": "sm_89", + "arch_list": ["sm_80", "sm_86"], "cuda_available": True, + "status": "mismatch", "message": "PyTorch 2.1 does not support sm_89", + "nightly_url": "https://download.pytorch.org/whl/nightly/cu121", + }, + }, + } + + +class TestFormatResultHtml: + def test_is_self_contained_fragment(self, sample_output): + html = format_result_html(sample_output) + assert html.startswith('
    ") + # Self-contained: no external stylesheet / script tags. + assert "alert(1)" not in html + assert "<script>" in html + + def test_handles_missing_optional_checks(self): + minimal = { + "machine": {}, "status": "pass", "timestamp": "", + "summary": {"issues_count": 0}, "checks": {}, + } + html = format_result_html(minimal) + assert html.startswith('
    Date: Mon, 15 Jun 2026 23:10:57 +0530 Subject: [PATCH 2/2] docs: document Jupyter check() API in README; ignore scratch notebook --- .gitignore | 2 ++ README.md | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/.gitignore b/.gitignore index 728898c..e6ab8e7 100644 --- a/.gitignore +++ b/.gitignore @@ -193,3 +193,5 @@ src/env_doctor/web/* PR_SUMMARY.md CHANGES-cuda-install-run.md *.stackdump +xyz.ipynb + diff --git a/README.md b/README.md index c3aa214..6a068a0 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ It takes **5 seconds** to find out if your environment is broken - and exactly h | **One-Command Diagnosis** | Check compatibility: GPU Driver → CUDA Toolkit → cuDNN → PyTorch/TensorFlow/JAX | | **Compute Capability Check** | Detect GPU architecture mismatches — catches why `torch.cuda.is_available()` returns `False` on new GPUs (e.g. Blackwell) even when driver and CUDA are healthy | | **Python Version Compatibility** | Detect Python version conflicts with AI libraries and dependency cascade impacts | +| **Jupyter / Notebook Output** | `from env_doctor import check; check()` renders a rich HTML diagnosis inline in Jupyter, Colab, or VS Code notebooks — falls back to text in the terminal | | **CUDA Auto-Installer** | Execute CUDA Toolkit installation directly with `--run`; CI-friendly with `--yes`; preview with `--dry-run` | | **Safe Install Commands** | Get the exact `pip install` command that works with YOUR driver | | **Extension Library Support** | Install compilation packages (flash-attn, SageAttention, auto-gptq, apex, xformers) with CUDA version matching | @@ -211,6 +212,18 @@ env-doctor check pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/cu126 ``` +### Notebook / Jupyter Output + +Inside a Jupyter, Colab, or VS Code notebook, the Python API renders a rich, self-contained HTML report inline: + +```python +from env_doctor import check +check() # auto-renders HTML in notebooks, prints text in terminals +``` + +Install the optional extra for notebook support: `pip install "env-doctor[notebook]"`. +You can also force HTML from the CLI with `env-doctor check --format html > report.html`. + ### Check Python Version Compatibility ```bash