diff --git a/alloc/lib/dashboard.py b/alloc/lib/dashboard.py index fb43577..f90e60e 100644 --- a/alloc/lib/dashboard.py +++ b/alloc/lib/dashboard.py @@ -251,7 +251,8 @@ def crawl_package( # Collect all .py files (skip __pycache__) py_files = sorted( - p for p in pkg.rglob("*.py") if "__pycache__" not in p.parts + p for p in pkg.rglob("*.py") + if "__pycache__" not in p.parts and ".venv" not in p.parts ) modules: list[ModuleStats] = [] diff --git a/alloc/lib/publish_dashboard.py b/alloc/lib/publish_dashboard.py index a8a51b7..703b218 100644 --- a/alloc/lib/publish_dashboard.py +++ b/alloc/lib/publish_dashboard.py @@ -5,6 +5,8 @@ colour-coded by severity. An optional ``--sync`` CLI flag pushes the generated HTML to a ``gh-pages`` branch for GitHub Pages hosting. +Supports single-package view and multi-package comparison mode. + Usage ----- from alloc.lib.publish_dashboard import generate_html, publish @@ -16,6 +18,9 @@ python -m alloc.lib.publish_dashboard [--json PATH] [--output PATH] [--sync] + python -m alloc.lib.publish_dashboard \ + --compare alloc/docs_dashboard_metadata.json new-trader.json + Signals ------- * **S1** — no tests (orange) @@ -81,157 +86,136 @@ def _escape_html(text: str) -> str: def _signal_badge(signal: str) -> str: - """Return an HTML span for a single signal badge. - - Parameters - ---------- - signal : str - Signal string like ``"S1:no_tests"`` or ``"S4:lint_errors(3)"``. - - Returns - ------- - str - HTML ```` element with colour-coded styling. - """ + """Return an HTML span for a single signal badge.""" key = signal.split(":")[0] label = signal.split(":", 1)[1] if ":" in signal else key - bg, fg = SIGNAL_COLORS.get(key, ("#e9ecef", "#495057")) icon = SIGNAL_ICONS.get(key, "⚠️") return ( - f'' + f'' f"{icon} {_escape_html(label)}" ) -def _module_row(mod: dict[str, Any]) -> str: - """Return an HTML table row for a single module. - - Parameters - ---------- - mod : dict - One module entry from the dashboard metadata. - - Returns - ------- - str - HTML ```` element. - """ +def _module_card(mod: dict[str, Any]) -> str: + """Return an HTML module card div.""" path = _escape_html(mod.get("path", "?")) lines = mod.get("lines", 0) functions = mod.get("functions", 0) classes = mod.get("classes", 0) tests = mod.get("test_count", 0) - has_doc = mod.get("has_docstring", False) signals = mod.get("signals", []) - # Docstring indicator - doc_icon = "✅" if has_doc else "❌" - - # Test coverage bar - test_pct = min(tests * 10, 100) if tests > 0 else 0 - test_bar = ( - f'
' - f'
' - f"
" - ) - - # Signal badges badges_html = " ".join(_signal_badge(s) for s in signals) - signals_cell = badges_html if badges_html else '✅ clear' + if badges_html: + status_html = badges_html + else: + status_html = 'clear' + + border_color = "#22c55e" if not signals else "#c0392b" return ( - f"" - f"{path}" - f"{lines}" - f"{functions}" - f"{classes}" - f"{doc_icon}" - f"{tests} {test_bar}" - f"{signals_cell}" - f"" + f'
' + f'
{path}
' + f'
{status_html}
' + f'
' + f'CLASSES: {classes}' + f'FUNCS: {functions}' + f'LINES: {lines}' + f'TESTS: {tests}' + f'
' + f'
' ) -def _summary_card(title: str, value: str, icon: str = "") -> str: - """Return an HTML summary card div. - - Parameters - ---------- - title : str - Card title (e.g. "Modules"). - value : str - Card value (e.g. "18"). - icon : str - Optional emoji icon. - - Returns - ------- - str - HTML ``
`` card element. - """ +def _summary_card(title: str, value: str, card_class: str = "") -> str: + """Return an HTML stat card div.""" + cls = f"stat-card {card_class}" if card_class else "stat-card" return ( - f'
' - f"
{icon}
" - f"
{_escape_html(value)}
" - f"
{_escape_html(title)}
" - f"
" + f'
' + f'
{_escape_html(value)}
' + f'
{_escape_html(title)}
' + f'
' ) def _signal_summary_cards(signals_summary: dict[str, int]) -> str: - """Return HTML cards for each signal type count. - - Parameters - ---------- - signals_summary : dict - Mapping of signal key (S1-S4) to count. - - Returns - ------- - str - HTML fragment with signal summary cards. - """ + """Return HTML stat cards for each signal type count.""" if not signals_summary: - return '
0
' \ - '
All Clear ✅
' + return '
0
' \ + '
All Clear
' cards = [] for key in sorted(signals_summary.keys()): count = signals_summary[key] - bg, fg = SIGNAL_COLORS.get(key, ("#e9ecef", "#495057")) - icon = SIGNAL_ICONS.get(key, "⚠️") label = SIGNAL_LABELS.get(key, key) cards.append( - f'
' - f"
{icon}
" - f"
{count}
" - f"
{label}
" - f"
" + f'
' + f'
{count}
' + f'
{label}
' + f'
' ) return "\n".join(cards) -def generate_html(metadata: dict[str, Any]) -> str: - """Generate a standalone HTML dashboard page from metadata. +def _comparison_section(packages: list[dict[str, Any]]) -> str: + """Generate a comparison table between multiple packages.""" + rows = [] + metrics = [ + ("Modules", "total_modules"), + ("Lines", "total_lines"), + ("Functions", "total_functions"), + ("Classes", "total_classes"), + ("Tests", "total_tests"), + ("S1: No Tests", "signals_summary.S1"), + ("S2: Oversized", "signals_summary.S2"), + ("S3: Dead Code", "signals_summary.S3"), + ("S4: Lint Errors", "signals_summary.S4"), + ] + + headers = "".join( + f'{_escape_html(pkg.get("package", "?"))}' + for pkg in packages + ) + + for label, key in metrics: + cells = [] + for pkg in packages: + if "." in key: + parent, child = key.split(".") + val = pkg.get(parent, {}).get(child, 0) + else: + val = pkg.get(key, 0) + formatted = f"{val:,}" if isinstance(val, int) and val > 100 else str(val) + cells.append(f'{formatted}') - The output is a self-contained HTML document with inline CSS and JS — - no external dependencies required. Works on mobile and desktop via - responsive CSS Grid/Flexbox layout. + cells.insert(0, f'{label}') + rows.append("" + "".join(cells) + "") + + return ( + f'
' + f'
Shape Comparison
' + f'
' + f'' + f'{headers}' + f'{"".join(rows)}' + f'
Metric
' + f'
' + f'
' + ) + + +def generate_html( + metadata: dict[str, Any], + compare_with: list[dict[str, Any]] | None = None, +) -> str: + """Generate a standalone HTML dashboard page from metadata. Parameters ---------- metadata : dict The deserialised JSON output of :func:`dashboard.generate_json`. - Must contain keys: ``package``, ``total_modules``, ``total_lines``, - ``total_functions``, ``total_classes``, ``total_tests``, - ``signals_summary``, ``modules``. - - Returns - ------- - str - Complete HTML document string. + compare_with : list of dict, optional + Additional package metadata dicts to compare against. """ pkg = _escape_html(metadata.get("package", "unknown")) total_modules = metadata.get("total_modules", 0) @@ -242,281 +226,420 @@ def generate_html(metadata: dict[str, Any]) -> str: signals_summary = metadata.get("signals_summary", {}) modules = metadata.get("modules", []) - # Module rows - module_rows = "\n".join(_module_row(m) for m in modules) + module_cards = "\n".join(_module_card(m) for m in modules) - # Summary cards summary_cards = "\n".join([ - _summary_card("Modules", str(total_modules), "📁"), - _summary_card("Lines", f"{total_lines:,}", "📝"), - _summary_card("Functions", str(total_functions), "⚙️"), - _summary_card("Classes", str(total_classes), "🏗️"), - _summary_card("Tests", str(total_tests), "🧪"), + _summary_card("Modules", str(total_modules)), + _summary_card("Lines", f"{total_lines:,}"), + _summary_card("Functions", str(total_functions)), + _summary_card("Classes", str(total_classes)), + _summary_card("Tests", str(total_tests)), ]) - # Signal summary signal_cards = _signal_summary_cards(signals_summary) - # Timestamp now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + # Build comparison section if multiple packages + comparison_html = "" + all_packages = [metadata] + (compare_with or []) + if len(all_packages) > 1: + comparison_html = _comparison_section(all_packages) + + # Build additional package sections + other_sections = "" + for other in (compare_with or []): + other_pkg = _escape_html(other.get("package", "unknown")) + other_modules = other.get("modules", []) + other_cards = "\n".join(_module_card(m) for m in other_modules) + other_total_m = other.get("total_modules", 0) + + other_sections += f""" +
+
{other_pkg} — Module Map ({other_total_m} modules)
+
+ {other_cards} +
+
+""" + html = f""" -{pkg} — Health Dashboard +{pkg} // HEALTH DASHBOARD
- -
-

📊 {pkg} Health Dashboard

-
Generated: {now}
-
- -
+ +
+

{pkg} // HEALTH DASHBOARD

+
codebase projection — {total_modules} modules scanned · {now}
+
+ + +
+
Surface Stats
+
{summary_cards}
+
- -
+ +
+
Health Signals
+
{signal_cards}
- - -
-

Signal Legend

-
🧪 S1 — No Tests
-
📦 S2 — Oversized - (>200 lines & >15 funcs)
-
💀 S3 — Dead Code (0 internal imports)
-
🔧 S4 — Lint/Type Errors
+
+
S1 No Tests: {signals_summary.get("S1", 0)}
+
S2 Oversized: {signals_summary.get("S2", 0)}
+
S3 Dead Code: {signals_summary.get("S3", 0)}
+
S4 Lint Errors: {signals_summary.get("S4", 0)}
+
+ + +{comparison_html} - + +
+
{pkg} — Module Map ({total_modules} modules)
- - +
- - -
- - - - - - - - - - - - - - {module_rows} - -
ModuleLinesFuncsClassesDocTestsSignals
+
+ {module_cards}
+
- - +{other_sections} + + +
@@ -544,22 +667,7 @@ def publish( output_path: str | Path = "dashboard.html", sync: bool = False, ) -> Path: - """Write HTML to *output_path* and optionally sync to ``gh-pages``. - - Parameters - ---------- - html : str - The HTML document string (from :func:`generate_html`). - output_path : str or Path - File path to write the HTML to. - sync : bool - If ``True``, push the HTML to a ``gh-pages`` branch via git. - - Returns - ------- - Path - The path where the HTML was written. - """ + """Write HTML to *output_path* and optionally sync to ``gh-pages``.""" out = Path(output_path) out.parent.mkdir(parents=True, exist_ok=True) out.write_text(html, encoding="utf-8") @@ -577,25 +685,8 @@ def publish( def _sync_to_ghpages(html_path: Path) -> None: - """Push *html_path* to the ``gh-pages`` branch. - - Uses git subcommands to: - 1. Create or checkout ``gh-pages`` branch - 2. Copy the HTML file to the branch root - 3. Commit and push - - Parameters - ---------- - html_path : Path - Path to the generated HTML file. - - Raises - ------ - RuntimeError - If git is not available or push fails. - """ + """Push *html_path* to the ``gh-pages`` branch.""" try: - # Check git is available subprocess.run( ["git", "--version"], capture_output=True, @@ -605,7 +696,6 @@ def _sync_to_ghpages(html_path: Path) -> None: except (FileNotFoundError, subprocess.CalledProcessError) as exc: raise RuntimeError(f"git not available: {exc}") from exc - # Determine repo root try: result = subprocess.run( ["git", "rev-parse", "--show-toplevel"], @@ -621,11 +711,9 @@ def _sync_to_ghpages(html_path: Path) -> None: gh_pages_dir = repo_root / ".gh-pages-staging" gh_pages_dir.mkdir(exist_ok=True) - # Copy HTML to staging dest = gh_pages_dir / html_path.name dest.write_text(html_path.read_text(encoding="utf-8"), encoding="utf-8") - # Stage, commit, push steps = [ ["git", "-C", str(repo_root), "checkout", "gh-pages"], ["git", "-C", str(repo_root), "cp", str(dest), str(repo_root / html_path.name)], @@ -635,19 +723,16 @@ def _sync_to_ghpages(html_path: Path) -> None: ["git", "-C", str(repo_root), "push", "origin", "gh-pages"], ] - # Handle "nothing to commit" gracefully for step in steps: cmd_str = " ".join(step) logger.debug("Running: %s", cmd_str) result = subprocess.run(step, capture_output=True, text=True, timeout=60) if result.returncode != 0: - # "nothing to commit" is acceptable if "nothing to commit" in result.stderr.lower(): logger.info("No changes to commit, skipping") break logger.warning("Command failed: %s\nstderr: %s", cmd_str, result.stderr) - # Clean up staging try: import shutil shutil.rmtree(gh_pages_dir) @@ -663,24 +748,7 @@ def _sync_to_ghpages(html_path: Path) -> None: def main() -> None: - """CLI entry point for publish_dashboard. - - Usage:: - - python -m alloc.lib.publish_dashboard [--json PATH] [--output PATH] [--sync] - - If ``--json`` is provided, reads metadata from that file. - Otherwise generates fresh metadata via :mod:`alloc.lib.dashboard`. - - Options - ------- - --json PATH - Path to pre-generated dashboard JSON. - --output PATH - Output HTML file path (default: ``dashboard.html``). - --sync - Push generated HTML to ``gh-pages`` branch. - """ + """CLI entry point for publish_dashboard.""" parser = argparse.ArgumentParser( description="Generate HTML dashboard from alloc metadata" ) @@ -691,6 +759,13 @@ def main() -> None: default=None, help="Path to pre-generated dashboard JSON", ) + parser.add_argument( + "--compare", + nargs="+", + type=str, + default=None, + help="Paths to additional JSON files for comparison", + ) parser.add_argument( "--output", type=str, @@ -704,7 +779,7 @@ def main() -> None: ) args = parser.parse_args() - # Load or generate metadata + # Load primary metadata if args.json_path: json_path = Path(args.json_path) if not json_path.exists(): @@ -717,8 +792,18 @@ def main() -> None: json_str = _gen_json() metadata = json.loads(json_str) + # Load comparison metadata + compare_with = [] + if args.compare: + for path_str in args.compare: + p = Path(path_str) + if p.exists(): + compare_with.append(json.loads(p.read_text(encoding="utf-8"))) + else: + logger.warning("Comparison file not found: %s", p) + # Generate and publish - html = generate_html(metadata) + html = generate_html(metadata, compare_with=compare_with if compare_with else None) publish(html, output_path=args.output, sync=args.sync) print(f"Dashboard published to {args.output}") diff --git a/tests/test_publish_dashboard.py b/tests/test_publish_dashboard.py index de28cda..e88bca9 100644 --- a/tests/test_publish_dashboard.py +++ b/tests/test_publish_dashboard.py @@ -14,7 +14,7 @@ SIGNAL_LABELS, _escape_html, _signal_badge, - _module_row, + _module_card, generate_html, publish, ) @@ -129,10 +129,10 @@ def test_s4_badge_with_count(self) -> None: assert "badge-s4" in html assert "lint_errors(3)" in html - def test_badge_has_inline_style(self) -> None: + def test_badge_has_css_class(self) -> None: html = _signal_badge("S2:oversized") - assert "background:" in html - assert "color:" in html + assert "badge-s2" in html + assert 'class="badge' in html # --------------------------------------------------------------------------- @@ -140,19 +140,19 @@ def test_badge_has_inline_style(self) -> None: # --------------------------------------------------------------------------- -class TestModuleRow: - def test_returns_tr_element(self, sample_metadata: dict) -> None: - row = _module_row(sample_metadata["modules"][0]) - assert row.startswith("") - assert row.endswith("") +class TestModuleCard: + def test_returns_div_element(self, sample_metadata: dict) -> None: + card = _module_card(sample_metadata["modules"][0]) + assert card.startswith('
") def test_contains_module_path(self, sample_metadata: dict) -> None: - row = _module_row(sample_metadata["modules"][0]) - assert "alloc.__init__" in row + card = _module_card(sample_metadata["modules"][0]) + assert "alloc.__init__" in card def test_clear_module_has_clear_badge(self, sample_metadata: dict) -> None: - row = _module_row(sample_metadata["modules"][2]) # no signals - assert "clear" in row + card = _module_card(sample_metadata["modules"][2]) # no signals + assert "clear" in card # --------------------------------------------------------------------------- @@ -199,7 +199,7 @@ def test_contains_inline_css(self, sample_metadata: dict) -> None: def test_contains_inline_js(self, sample_metadata: dict) -> None: html = generate_html(sample_metadata) assert "