from .collector import Collector
from .pstats_collector import PstatsCollector
from .stack_collector import CollapsedStackCollector
+from .heatmap_collector import HeatmapCollector
from .gecko_collector import GeckoCollector
from .string_table import StringTable
-__all__ = ("Collector", "PstatsCollector", "CollapsedStackCollector", "GeckoCollector", "StringTable")
+__all__ = ("Collector", "PstatsCollector", "CollapsedStackCollector", "HeatmapCollector", "GeckoCollector", "StringTable")
--- /dev/null
+import importlib.resources
+
+
+def get_combined_css(component: str) -> str:
+ template_dir = importlib.resources.files(__package__)
+
+ base_css = (template_dir / "_shared_assets" / "base.css").read_text(encoding="utf-8")
+
+ if component == "flamegraph":
+ component_css = (
+ template_dir / "_flamegraph_assets" / "flamegraph.css"
+ ).read_text(encoding="utf-8")
+ elif component == "heatmap":
+ component_css = (template_dir / "_heatmap_assets" / "heatmap.css").read_text(
+ encoding="utf-8"
+ )
+ else:
+ raise ValueError(
+ f"Unknown component: {component}. Expected 'flamegraph' or 'heatmap'."
+ )
+
+ return f"{base_css}\n\n{component_css}"
/* ==========================================================================
- Flamegraph Viewer - CSS
- Python-branded profiler with dark/light theme support
- ========================================================================== */
+ Flamegraph Viewer - Component-Specific CSS
-/* --------------------------------------------------------------------------
- CSS Variables & Theme System
- -------------------------------------------------------------------------- */
-
-:root {
- /* Typography */
- --font-sans: "Source Sans Pro", "Lucida Grande", "Lucida Sans Unicode",
- "Geneva", "Verdana", sans-serif;
- --font-mono: 'SF Mono', 'Monaco', 'Consolas', 'Liberation Mono', monospace;
-
- /* Python brand colors (theme-independent) */
- --python-blue: #3776ab;
- --python-blue-light: #4584bb;
- --python-blue-lighter: #5592cc;
- --python-gold: #ffd43b;
- --python-gold-dark: #ffcd02;
- --python-gold-light: #ffdc5c;
-
- /* Heat palette - defined per theme below */
-
- /* Layout */
- --sidebar-width: 280px;
- --sidebar-collapsed: 44px;
- --topbar-height: 52px;
- --statusbar-height: 32px;
-
- /* Transitions */
- --transition-fast: 0.15s ease;
- --transition-normal: 0.25s ease;
-}
-
-/* Light theme (default) - Python yellow-to-blue heat palette */
-:root, [data-theme="light"] {
- --bg-primary: #ffffff;
- --bg-secondary: #f8f9fa;
- --bg-tertiary: #e9ecef;
- --border: #e9ecef;
- --border-subtle: #f0f2f5;
-
- --text-primary: #2e3338;
- --text-secondary: #5a6c7d;
- --text-muted: #8b949e;
-
- --accent: #3776ab;
- --accent-hover: #2d5aa0;
- --accent-glow: rgba(55, 118, 171, 0.15);
-
- --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
- --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
- --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.15);
-
- --header-gradient: linear-gradient(135deg, #3776ab 0%, #4584bb 100%);
-
- /* Light mode heat palette - blue to yellow to orange to red (cold to hot) */
- --heat-1: #d6e9f8;
- --heat-2: #a8d0ef;
- --heat-3: #7ba3d1;
- --heat-4: #ffe6a8;
- --heat-5: #ffd43b;
- --heat-6: #ffb84d;
- --heat-7: #ff9966;
- --heat-8: #ff6347;
-}
-
-/* Dark theme - teal-to-orange heat palette */
-[data-theme="dark"] {
- --bg-primary: #0d1117;
- --bg-secondary: #161b22;
- --bg-tertiary: #21262d;
- --border: #30363d;
- --border-subtle: #21262d;
-
- --text-primary: #e6edf3;
- --text-secondary: #8b949e;
- --text-muted: #6e7681;
-
- --accent: #58a6ff;
- --accent-hover: #79b8ff;
- --accent-glow: rgba(88, 166, 255, 0.15);
-
- --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
- --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
- --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
-
- --header-gradient: linear-gradient(135deg, #21262d 0%, #30363d 100%);
-
- /* Dark mode heat palette - dark blue to teal to yellow to orange (cold to hot) */
- --heat-1: #1e3a5f;
- --heat-2: #2d5580;
- --heat-3: #4a7ba7;
- --heat-4: #5a9fa8;
- --heat-5: #7ec488;
- --heat-6: #c4de6a;
- --heat-7: #f4d44d;
- --heat-8: #ff6b35;
-}
+ DEPENDENCY: Requires _shared_assets/base.css to be loaded first
+ This file extends the shared foundation with flamegraph-specific styles.
+ ========================================================================== */
/* --------------------------------------------------------------------------
- Base Styles
+ Layout Overrides (Flamegraph-specific)
-------------------------------------------------------------------------- */
-*, *::before, *::after {
- box-sizing: border-box;
-}
-
html, body {
- margin: 0;
- padding: 0;
height: 100%;
overflow: hidden;
}
-body {
- font-family: var(--font-sans);
- font-size: 14px;
- line-height: 1.6;
- color: var(--text-primary);
- background: var(--bg-primary);
- transition: background var(--transition-normal), color var(--transition-normal);
-}
-
-/* --------------------------------------------------------------------------
- Layout Structure
- -------------------------------------------------------------------------- */
-
.app-layout {
- display: flex;
- flex-direction: column;
height: 100vh;
}
}
/* --------------------------------------------------------------------------
- Top Bar
+ Search Input (Flamegraph-specific)
-------------------------------------------------------------------------- */
-.top-bar {
- height: 56px;
- background: var(--header-gradient);
- display: flex;
- align-items: center;
- padding: 0 16px;
- gap: 16px;
- flex-shrink: 0;
- box-shadow: 0 2px 10px rgba(55, 118, 171, 0.25);
- border-bottom: 2px solid var(--python-gold);
-}
-
-/* Brand / Logo */
-.brand {
- display: flex;
- align-items: center;
- gap: 12px;
- color: white;
- text-decoration: none;
- flex-shrink: 0;
-}
-
-.brand-logo {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- flex-shrink: 0;
-}
-
-/* Style the inlined SVG/img inside brand-logo */
-.brand-logo svg,
-.brand-logo img {
- width: 28px;
- height: 28px;
- display: block;
- object-fit: contain;
- filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
-}
-
-.brand-info {
- display: flex;
- flex-direction: column;
- line-height: 1.15;
-}
-
-.brand-text {
- font-weight: 700;
- font-size: 16px;
- letter-spacing: -0.3px;
- text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
-}
-
-.brand-subtitle {
- font-weight: 500;
- font-size: 10px;
- opacity: 0.9;
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-
-.brand-divider {
- width: 1px;
- height: 16px;
- background: rgba(255, 255, 255, 0.3);
-}
-
-/* Search */
.search-wrapper {
flex: 1;
max-width: 360px;
display: flex;
}
-/* Toolbar */
-.toolbar {
- display: flex;
- align-items: center;
- gap: 6px;
- margin-left: auto;
-}
-
-.toolbar-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 32px;
- height: 32px;
- padding: 0;
- font-size: 15px;
- color: white;
- background: rgba(255, 255, 255, 0.12);
- border: 1px solid rgba(255, 255, 255, 0.18);
- border-radius: 6px;
- cursor: pointer;
- transition: all var(--transition-fast);
-}
-
-.toolbar-btn:hover {
- background: rgba(255, 255, 255, 0.22);
- border-color: rgba(255, 255, 255, 0.35);
-}
-
-.toolbar-btn:active {
- transform: scale(0.95);
-}
-
/* --------------------------------------------------------------------------
Sidebar
-------------------------------------------------------------------------- */
animation: shimmer 2s ease-in-out infinite;
}
-@keyframes shimmer {
- 0% { left: -100%; }
- 100% { left: 100%; }
-}
-
/* --------------------------------------------------------------------------
Thread Stats Grid (in Sidebar)
-------------------------------------------------------------------------- */
opacity: 0.25;
}
-/* --------------------------------------------------------------------------
- Status Bar
- -------------------------------------------------------------------------- */
-
-.status-bar {
- height: var(--statusbar-height);
- background: var(--bg-secondary);
- border-top: 1px solid var(--border);
- display: flex;
- align-items: center;
- padding: 0 16px;
- gap: 16px;
- font-family: var(--font-mono);
- font-size: 11px;
- color: var(--text-secondary);
- flex-shrink: 0;
-}
-
-.status-item {
- display: flex;
- align-items: center;
- gap: 5px;
-}
-
-.status-item::before {
- content: '';
- width: 4px;
- height: 4px;
- background: var(--python-gold);
- border-radius: 50%;
-}
-
-.status-item:first-child::before {
- display: none;
-}
-
-.status-label {
- color: var(--text-muted);
-}
-
-.status-value {
- color: var(--text-primary);
- font-weight: 500;
-}
-
-.status-value.accent {
- color: var(--accent);
- font-weight: 600;
-}
-
/* --------------------------------------------------------------------------
Tooltip
-------------------------------------------------------------------------- */
}
/* --------------------------------------------------------------------------
- Animations
- -------------------------------------------------------------------------- */
-
-@keyframes fadeIn {
- from { opacity: 0; }
- to { opacity: 1; }
-}
-
-@keyframes slideUp {
- from {
- opacity: 0;
- transform: translateY(12px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-/* --------------------------------------------------------------------------
- Focus States (Accessibility)
- -------------------------------------------------------------------------- */
-
-button:focus-visible,
-select:focus-visible,
-input:focus-visible {
- outline: 2px solid var(--python-gold);
- outline-offset: 2px;
-}
-
-/* --------------------------------------------------------------------------
- Responsive
+ Responsive (Flamegraph-specific)
-------------------------------------------------------------------------- */
@media (max-width: 900px) {
width: var(--sidebar-collapsed);
}
- .brand-subtitle {
- display: none;
- }
-
.search-wrapper {
max-width: 220px;
}
}
@media (max-width: 600px) {
- .toolbar-btn:not(.theme-toggle) {
- display: none;
- }
-
.search-wrapper {
max-width: 160px;
}
--- /dev/null
+/* ==========================================================================
+ Heatmap Viewer - Component-Specific CSS
+
+ DEPENDENCY: Requires _shared_assets/base.css to be loaded first
+ This file extends the shared foundation with heatmap-specific styles.
+ ========================================================================== */
+
+/* --------------------------------------------------------------------------
+ Layout Overrides (Heatmap-specific)
+ -------------------------------------------------------------------------- */
+
+.app-layout {
+ min-height: 100vh;
+}
+
+/* Sticky top bar for heatmap views */
+.top-bar {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+
+/* Back link in toolbar */
+.back-link {
+ color: white;
+ text-decoration: none;
+ padding: 6px 14px;
+ background: rgba(255, 255, 255, 0.12);
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 500;
+ transition: all var(--transition-fast);
+}
+
+.back-link:hover {
+ background: rgba(255, 255, 255, 0.22);
+ border-color: rgba(255, 255, 255, 0.35);
+}
+
+/* --------------------------------------------------------------------------
+ Main Content Area
+ -------------------------------------------------------------------------- */
+
+.main-content {
+ flex: 1;
+ padding: 24px 3%;
+ width: 100%;
+ max-width: 100%;
+}
+
+/* --------------------------------------------------------------------------
+ Stats Summary Cards - Enhanced with Icons & Animations
+ -------------------------------------------------------------------------- */
+
+.stats-summary {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 12px;
+ margin-bottom: 24px;
+}
+
+.stat-card {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ background: var(--bg-primary);
+ border: 2px solid var(--border);
+ border-radius: 10px;
+ padding: 14px 16px;
+ transition: all var(--transition-fast);
+ animation: slideUp 0.5s ease-out backwards;
+ animation-delay: calc(var(--i, 0) * 0.08s);
+ position: relative;
+ overflow: hidden;
+}
+
+.stat-card::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 3px;
+ background: linear-gradient(90deg, var(--python-blue), var(--python-gold));
+ opacity: 0;
+ transition: opacity var(--transition-fast);
+}
+
+.stat-card:nth-child(1) { --i: 0; --card-color: 55, 118, 171; }
+.stat-card:nth-child(2) { --i: 1; --card-color: 40, 167, 69; }
+.stat-card:nth-child(3) { --i: 2; --card-color: 255, 193, 7; }
+.stat-card:nth-child(4) { --i: 3; --card-color: 111, 66, 193; }
+.stat-card:nth-child(5) { --i: 4; --card-color: 220, 53, 69; }
+.stat-card:nth-child(6) { --i: 5; --card-color: 23, 162, 184; }
+
+.stat-card:hover {
+ border-color: rgba(var(--card-color), 0.6);
+ background: linear-gradient(135deg, rgba(var(--card-color), 0.08) 0%, var(--bg-primary) 100%);
+ transform: translateY(-2px);
+ box-shadow: 0 4px 16px rgba(var(--card-color), 0.15);
+}
+
+.stat-card:hover::before {
+ opacity: 1;
+}
+
+.stat-icon {
+ width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ background: linear-gradient(135deg, rgba(var(--card-color), 0.15) 0%, rgba(var(--card-color), 0.05) 100%);
+ border: 1px solid rgba(var(--card-color), 0.2);
+ border-radius: 10px;
+ flex-shrink: 0;
+ transition: all var(--transition-fast);
+}
+
+.stat-card:hover .stat-icon {
+ transform: scale(1.05) rotate(-2deg);
+ background: linear-gradient(135deg, rgba(var(--card-color), 0.25) 0%, rgba(var(--card-color), 0.1) 100%);
+}
+
+.stat-data {
+ flex: 1;
+ min-width: 0;
+}
+
+.stat-value {
+ font-family: var(--font-mono);
+ font-size: 1.35em;
+ font-weight: 800;
+ color: rgb(var(--card-color));
+ display: block;
+ line-height: 1.1;
+ letter-spacing: -0.3px;
+}
+
+.stat-label {
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ margin-top: 2px;
+}
+
+/* Sparkline decoration for stats */
+.stat-sparkline {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 30px;
+ opacity: 0.1;
+ background: linear-gradient(180deg,
+ transparent 0%,
+ rgba(var(--card-color), 0.3) 100%
+ );
+ pointer-events: none;
+}
+
+/* --------------------------------------------------------------------------
+ Rate Cards (Error Rate, Missed Samples) with Progress Bars
+ -------------------------------------------------------------------------- */
+
+.rate-card {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ background: var(--bg-primary);
+ border: 2px solid var(--border);
+ border-radius: 12px;
+ padding: 18px 20px;
+ transition: all var(--transition-fast);
+ animation: slideUp 0.5s ease-out backwards;
+ position: relative;
+ overflow: hidden;
+}
+
+.rate-card:nth-child(5) { animation-delay: 0.32s; --rate-color: 220, 53, 69; }
+.rate-card:nth-child(6) { animation-delay: 0.40s; --rate-color: 255, 152, 0; }
+
+.rate-card:hover {
+ border-color: rgba(var(--rate-color), 0.5);
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(var(--rate-color), 0.15);
+}
+
+.rate-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.rate-info {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.rate-icon {
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ background: linear-gradient(135deg, rgba(var(--rate-color), 0.15) 0%, rgba(var(--rate-color), 0.05) 100%);
+ border: 1px solid rgba(var(--rate-color), 0.2);
+ border-radius: 10px;
+ flex-shrink: 0;
+}
+
+.rate-label {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.rate-value {
+ font-family: var(--font-mono);
+ font-size: 1.4em;
+ font-weight: 800;
+ color: rgb(var(--rate-color));
+}
+
+.rate-bar {
+ height: 8px;
+ background: var(--bg-tertiary);
+ border-radius: 4px;
+ overflow: hidden;
+ position: relative;
+}
+
+.rate-fill {
+ height: 100%;
+ border-radius: 4px;
+ transition: width 0.8s ease-out;
+ position: relative;
+ overflow: hidden;
+}
+
+.rate-fill.error {
+ background: linear-gradient(90deg, #dc3545 0%, #ff6b6b 100%);
+}
+
+.rate-fill.warning {
+ background: linear-gradient(90deg, #ff9800 0%, #ffc107 100%);
+}
+
+.rate-fill.good {
+ background: linear-gradient(90deg, #28a745 0%, #20c997 100%);
+}
+
+/* Shimmer animation on rate bars */
+.rate-fill::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(
+ 90deg,
+ transparent 0%,
+ rgba(255, 255, 255, 0.4) 50%,
+ transparent 100%
+ );
+ animation: shimmer 2.5s ease-in-out infinite;
+}
+
+/* --------------------------------------------------------------------------
+ Section Headers
+ -------------------------------------------------------------------------- */
+
+.section-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 16px;
+ padding-bottom: 12px;
+ border-bottom: 2px solid var(--python-gold);
+}
+
+.section-title {
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin: 0;
+ flex: 1;
+}
+
+/* --------------------------------------------------------------------------
+ Filter Controls
+ -------------------------------------------------------------------------- */
+
+.filter-controls {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ align-items: center;
+ margin-bottom: 16px;
+}
+
+.control-btn {
+ padding: 8px 16px;
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.control-btn:hover {
+ background: var(--accent);
+ color: white;
+ border-color: var(--accent);
+}
+
+/* --------------------------------------------------------------------------
+ Type Sections (stdlib, project, etc)
+ -------------------------------------------------------------------------- */
+
+.type-section {
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ overflow: hidden;
+ margin-bottom: 12px;
+}
+
+.type-header {
+ padding: 12px 16px;
+ background: var(--header-gradient);
+ color: white;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ user-select: none;
+ transition: all var(--transition-fast);
+ font-weight: 600;
+}
+
+.type-header:hover {
+ opacity: 0.95;
+}
+
+.type-icon {
+ font-size: 12px;
+ transition: transform var(--transition-fast);
+ min-width: 12px;
+}
+
+.type-title {
+ font-size: 14px;
+ flex: 1;
+}
+
+.type-stats {
+ font-size: 12px;
+ opacity: 0.9;
+ background: rgba(255, 255, 255, 0.15);
+ padding: 4px 10px;
+ border-radius: 4px;
+ font-family: var(--font-mono);
+}
+
+.type-content {
+ padding: 12px;
+}
+
+/* --------------------------------------------------------------------------
+ Folder Nodes (hierarchical structure)
+ -------------------------------------------------------------------------- */
+
+.folder-node {
+ margin-bottom: 6px;
+}
+
+.folder-header {
+ padding: 8px 12px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ user-select: none;
+ transition: all var(--transition-fast);
+}
+
+.folder-header:hover {
+ background: var(--accent-glow);
+ border-color: var(--accent);
+}
+
+.folder-icon {
+ font-size: 10px;
+ color: var(--accent);
+ transition: transform var(--transition-fast);
+ min-width: 12px;
+}
+
+.folder-name {
+ flex: 1;
+ font-weight: 500;
+ color: var(--text-primary);
+ font-size: 13px;
+}
+
+.folder-stats {
+ font-size: 11px;
+ color: var(--text-secondary);
+ background: var(--bg-tertiary);
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-family: var(--font-mono);
+}
+
+.folder-content {
+ padding-left: 20px;
+ margin-top: 6px;
+}
+
+/* --------------------------------------------------------------------------
+ File Items
+ -------------------------------------------------------------------------- */
+
+.files-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-top: 8px;
+}
+
+.file-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 8px 12px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-subtle);
+ border-radius: 6px;
+ transition: all var(--transition-fast);
+}
+
+.file-item:hover {
+ background: var(--bg-secondary);
+ border-color: var(--border);
+}
+
+.file-item .file-link {
+ flex: 1;
+ min-width: 0;
+ font-size: 13px;
+}
+
+.file-samples {
+ font-size: 12px;
+ color: var(--text-secondary);
+ font-weight: 600;
+ white-space: nowrap;
+ width: 130px;
+ flex-shrink: 0;
+ text-align: right;
+ font-family: var(--font-mono);
+}
+
+.heatmap-bar-container {
+ width: 120px;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+}
+
+.heatmap-bar {
+ flex-shrink: 0;
+ border-radius: 2px;
+}
+
+/* Links */
+.file-link {
+ color: var(--accent);
+ text-decoration: none;
+ font-weight: 500;
+ transition: color var(--transition-fast);
+}
+
+.file-link:hover {
+ color: var(--accent-hover);
+ text-decoration: underline;
+}
+
+/* --------------------------------------------------------------------------
+ Module Badges
+ -------------------------------------------------------------------------- */
+
+.module-badge {
+ display: inline-block;
+ padding: 3px 8px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.badge-stdlib {
+ background: rgba(40, 167, 69, 0.15);
+ color: #28a745;
+}
+
+.badge-site-packages {
+ background: rgba(0, 123, 255, 0.15);
+ color: #007bff;
+}
+
+.badge-project {
+ background: rgba(255, 193, 7, 0.2);
+ color: #d39e00;
+}
+
+.badge-other {
+ background: var(--bg-tertiary);
+ color: var(--text-secondary);
+}
+
+[data-theme="dark"] .badge-stdlib {
+ background: rgba(40, 167, 69, 0.25);
+ color: #5dd879;
+}
+
+[data-theme="dark"] .badge-site-packages {
+ background: rgba(88, 166, 255, 0.25);
+ color: #79b8ff;
+}
+
+[data-theme="dark"] .badge-project {
+ background: rgba(255, 212, 59, 0.25);
+ color: #ffd43b;
+}
+
+/* ==========================================================================
+ FILE VIEW STYLES (Code Display)
+ ========================================================================== */
+
+.code-view {
+ font-family: var(--font-mono);
+ min-height: 100vh;
+}
+
+/* Code Header (Top Bar for file view) */
+.code-header {
+ height: var(--topbar-height);
+ background: var(--header-gradient);
+ display: flex;
+ align-items: center;
+ padding: 0 16px;
+ gap: 16px;
+ box-shadow: 0 2px 10px rgba(55, 118, 171, 0.25);
+ border-bottom: 2px solid var(--python-gold);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+
+.code-header-content {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 94%;
+ max-width: 100%;
+ margin: 0 auto;
+}
+
+.code-header h1 {
+ font-size: 14px;
+ font-weight: 600;
+ color: white;
+ margin: 0;
+ font-family: var(--font-mono);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* File Stats Bar */
+.file-stats {
+ background: var(--bg-secondary);
+ padding: 16px 24px;
+ border-bottom: 1px solid var(--border);
+}
+
+.file-stats .stats-grid {
+ width: 94%;
+ max-width: 100%;
+ margin: 0 auto;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
+ gap: 12px;
+}
+
+.stat-item {
+ background: var(--bg-primary);
+ padding: 12px;
+ border-radius: 8px;
+ box-shadow: var(--shadow-sm);
+ text-align: center;
+ border: 1px solid var(--border);
+ transition: all var(--transition-fast);
+}
+
+.stat-item:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md);
+ border-color: var(--accent);
+}
+
+.stat-item .stat-value {
+ font-size: 1.4em;
+ font-weight: 700;
+ color: var(--accent);
+}
+
+.stat-item .stat-label {
+ color: var(--text-muted);
+ font-size: 10px;
+ margin-top: 2px;
+}
+
+/* Legend */
+.legend {
+ background: var(--bg-secondary);
+ padding: 12px 24px;
+ border-bottom: 1px solid var(--border);
+}
+
+.legend-content {
+ width: 94%;
+ max-width: 100%;
+ margin: 0 auto;
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ flex-wrap: wrap;
+}
+
+.legend-title {
+ font-weight: 600;
+ color: var(--text-primary);
+ font-size: 13px;
+ font-family: var(--font-sans);
+}
+
+.legend-gradient {
+ flex: 1;
+ max-width: 300px;
+ height: 24px;
+ background: linear-gradient(90deg,
+ var(--bg-tertiary) 0%,
+ var(--heat-2) 25%,
+ var(--heat-4) 50%,
+ var(--heat-6) 75%,
+ var(--heat-8) 100%
+ );
+ border-radius: 4px;
+ border: 1px solid var(--border);
+}
+
+.legend-labels {
+ display: flex;
+ gap: 12px;
+ font-size: 11px;
+ color: var(--text-muted);
+ font-family: var(--font-sans);
+}
+
+/* Toggle Switch Styles */
+.toggle-switch {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ user-select: none;
+ font-family: var(--font-sans);
+ transition: opacity var(--transition-fast);
+}
+
+.toggle-switch:hover {
+ opacity: 0.85;
+}
+
+.toggle-switch .toggle-label {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text-muted);
+ min-width: 55px;
+ text-align: right;
+ transition: color var(--transition-fast);
+}
+
+.toggle-switch .toggle-label:last-child {
+ text-align: left;
+}
+
+.toggle-switch .toggle-label.active {
+ color: var(--text-primary);
+ font-weight: 600;
+}
+
+.toggle-track {
+ position: relative;
+ width: 36px;
+ height: 20px;
+ background: var(--bg-tertiary);
+ border: 2px solid var(--border);
+ border-radius: 12px;
+ transition: all var(--transition-fast);
+ box-shadow: inset var(--shadow-sm);
+}
+
+.toggle-track:hover {
+ border-color: var(--text-muted);
+}
+
+.toggle-track.on {
+ background: var(--accent);
+ border-color: var(--accent);
+ box-shadow: 0 0 8px var(--accent-glow);
+}
+
+.toggle-track::after {
+ content: '';
+ position: absolute;
+ top: 1px;
+ left: 1px;
+ width: 14px;
+ height: 14px;
+ background: white;
+ border-radius: 50%;
+ box-shadow: var(--shadow-sm);
+ transition: all var(--transition-fast);
+}
+
+.toggle-track.on::after {
+ transform: translateX(16px);
+ box-shadow: var(--shadow-md);
+}
+
+/* Specific toggle overrides */
+#toggle-color-mode .toggle-track.on {
+ background: #8e44ad;
+ border-color: #8e44ad;
+ box-shadow: 0 0 8px rgba(142, 68, 173, 0.3);
+}
+
+#toggle-cold .toggle-track.on {
+ background: #e67e22;
+ border-color: #e67e22;
+ box-shadow: 0 0 8px rgba(230, 126, 34, 0.3);
+}
+
+/* Code Container */
+.code-container {
+ width: 94%;
+ max-width: 100%;
+ margin: 16px auto;
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: 8px 8px 8px 8px;
+ box-shadow: var(--shadow-sm);
+ /* Allow horizontal scroll for long lines, but don't clip sticky header */
+}
+
+/* Code Header Row */
+.code-header-row {
+ position: sticky;
+ top: var(--topbar-height);
+ z-index: 50;
+ display: flex;
+ background: var(--bg-secondary);
+ border-bottom: 2px solid var(--border);
+ font-weight: 700;
+ font-size: 11px;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ border-radius: 8px 8px 0 0;
+}
+
+.header-line-number {
+ flex-shrink: 0;
+ width: 60px;
+ padding: 8px 10px;
+ text-align: right;
+ border-right: 1px solid var(--border);
+}
+
+.header-samples-self,
+.header-samples-cumulative {
+ flex-shrink: 0;
+ width: 90px;
+ padding: 8px 10px;
+ text-align: right;
+ border-right: 1px solid var(--border);
+}
+
+.header-samples-self {
+ color: var(--heat-8);
+}
+
+.header-samples-cumulative {
+ color: var(--accent);
+}
+
+.header-content {
+ flex: 1;
+ padding: 8px 15px;
+}
+
+/* Code Lines */
+.code-line {
+ position: relative;
+ display: flex;
+ min-height: 20px;
+ line-height: 20px;
+ font-size: 13px;
+ transition: background var(--transition-fast);
+ scroll-margin-top: calc(var(--topbar-height) + 50px);
+}
+
+.code-line:hover {
+ filter: brightness(0.97);
+}
+
+[data-theme="dark"] .code-line:hover {
+ filter: brightness(1.1);
+}
+
+.line-number {
+ flex-shrink: 0;
+ width: 60px;
+ padding: 0 10px;
+ text-align: right;
+ color: var(--text-muted);
+ background: var(--bg-secondary);
+ border-right: 1px solid var(--border);
+ user-select: none;
+ transition: all var(--transition-fast);
+}
+
+.line-number:hover {
+ background: var(--accent);
+ color: white;
+ cursor: pointer;
+}
+
+.line-samples-self,
+.line-samples-cumulative {
+ flex-shrink: 0;
+ width: 90px;
+ padding: 0 10px;
+ text-align: right;
+ background: var(--bg-secondary);
+ border-right: 1px solid var(--border);
+ font-weight: 600;
+ user-select: none;
+ font-size: 12px;
+}
+
+.line-samples-self {
+ color: var(--heat-8);
+}
+
+.line-samples-cumulative {
+ color: var(--accent);
+}
+
+.line-content {
+ flex: 1;
+ padding: 0 15px;
+ white-space: pre;
+ overflow-x: auto;
+}
+
+/* Scrollbar Styling */
+.line-content::-webkit-scrollbar {
+ height: 6px;
+}
+
+.line-content::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 3px;
+}
+
+.line-content::-webkit-scrollbar-thumb:hover {
+ background: var(--text-muted);
+}
+
+/* Navigation Buttons */
+.line-nav-buttons {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ display: flex;
+ gap: 4px;
+ align-items: center;
+}
+
+.nav-btn {
+ padding: 2px 6px;
+ font-size: 12px;
+ font-weight: 500;
+ border: 1px solid var(--accent);
+ border-radius: 4px;
+ background: var(--bg-primary);
+ color: var(--accent);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ user-select: none;
+ line-height: 1;
+}
+
+.nav-btn:hover:not(:disabled) {
+ background: var(--accent);
+ color: white;
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-sm);
+}
+
+.nav-btn:active:not(:disabled) {
+ transform: translateY(0);
+}
+
+.nav-btn:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+ color: var(--text-muted);
+ background: var(--bg-secondary);
+ border-color: var(--border);
+}
+
+.nav-btn.caller {
+ color: var(--nav-caller);
+ border-color: var(--nav-caller);
+}
+
+.nav-btn.callee {
+ color: var(--nav-callee);
+ border-color: var(--nav-callee);
+}
+
+.nav-btn.caller:hover:not(:disabled) {
+ background: var(--nav-caller-hover);
+ color: white;
+}
+
+.nav-btn.callee:hover:not(:disabled) {
+ background: var(--nav-callee-hover);
+ color: white;
+}
+
+/* Highlighted target line */
+.code-line:target {
+ animation: highlight-line 2s ease-out;
+}
+
+@keyframes highlight-line {
+ 0% {
+ background: rgba(255, 212, 59, 0.6) !important;
+ outline: 3px solid var(--python-gold);
+ outline-offset: -3px;
+ }
+ 50% {
+ background: rgba(255, 212, 59, 0.5) !important;
+ outline: 3px solid var(--python-gold);
+ outline-offset: -3px;
+ }
+ 100% {
+ background: inherit;
+ outline: 3px solid transparent;
+ outline-offset: -3px;
+ }
+}
+
+/* Popup menu for multiple callees */
+.callee-menu {
+ position: absolute;
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ box-shadow: var(--shadow-lg);
+ padding: 8px;
+ z-index: 1000;
+ min-width: 250px;
+ max-width: 400px;
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.callee-menu-header {
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 8px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border);
+ font-size: 13px;
+ font-family: var(--font-sans);
+}
+
+.callee-menu-item {
+ padding: 8px;
+ margin: 4px 0;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: background var(--transition-fast);
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.callee-menu-item:hover {
+ background: var(--bg-secondary);
+}
+
+.callee-menu-func {
+ font-weight: 500;
+ color: var(--accent);
+ font-size: 12px;
+}
+
+.callee-menu-file {
+ font-size: 11px;
+ color: var(--text-muted);
+}
+
+.count-badge {
+ display: inline-block;
+ background: var(--accent);
+ color: white;
+ font-size: 10px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-weight: 600;
+ margin-left: 6px;
+}
+
+/* Callee menu scrollbar */
+.callee-menu::-webkit-scrollbar {
+ width: 6px;
+}
+
+.callee-menu::-webkit-scrollbar-track {
+ background: var(--bg-secondary);
+ border-radius: 3px;
+}
+
+.callee-menu::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 3px;
+}
+
+/* --------------------------------------------------------------------------
+ Scroll Minimap Marker
+ -------------------------------------------------------------------------- */
+
+#scroll_marker {
+ position: fixed;
+ z-index: 1000;
+ right: 0;
+ top: 0;
+ width: 12px;
+ height: 100%;
+ background: var(--bg-secondary);
+ border-left: 1px solid var(--border);
+ pointer-events: none;
+}
+
+#scroll_marker .marker {
+ position: absolute;
+ min-height: 3px;
+ width: 100%;
+ pointer-events: none;
+}
+
+#scroll_marker .marker.cold {
+ background: var(--heat-2);
+}
+
+#scroll_marker .marker.warm {
+ background: var(--heat-5);
+}
+
+#scroll_marker .marker.hot {
+ background: var(--heat-7);
+}
+
+#scroll_marker .marker.vhot {
+ background: var(--heat-8);
+}
+
+/* --------------------------------------------------------------------------
+ Responsive (Heatmap-specific)
+ -------------------------------------------------------------------------- */
+
+@media (max-width: 1100px) {
+ .stats-summary {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+@media (max-width: 900px) {
+ .main-content {
+ padding: 16px;
+ }
+}
+
+@media (max-width: 600px) {
+ .stats-summary {
+ grid-template-columns: 1fr;
+ }
+
+ .file-stats .stats-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .legend-content {
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ .legend-gradient {
+ width: 100%;
+ max-width: none;
+ }
+}
--- /dev/null
+// Tachyon Profiler - Heatmap JavaScript
+// Interactive features for the heatmap visualization
+// Aligned with Flamegraph viewer design patterns
+
+// ============================================================================
+// State Management
+// ============================================================================
+
+let currentMenu = null;
+let colorMode = 'self'; // 'self' or 'cumulative' - default to self
+let coldCodeHidden = false;
+
+// ============================================================================
+// Theme Support
+// ============================================================================
+
+function toggleTheme() {
+ const html = document.documentElement;
+ const current = html.getAttribute('data-theme') || 'light';
+ const next = current === 'light' ? 'dark' : 'light';
+ html.setAttribute('data-theme', next);
+ localStorage.setItem('heatmap-theme', next);
+
+ // Update theme button icon
+ const btn = document.getElementById('theme-btn');
+ if (btn) {
+ btn.innerHTML = next === 'dark' ? '☼' : '☾'; // sun or moon
+ }
+
+ // Rebuild scroll marker with new theme colors
+ buildScrollMarker();
+}
+
+function restoreUIState() {
+ // Restore theme
+ const savedTheme = localStorage.getItem('heatmap-theme');
+ if (savedTheme) {
+ document.documentElement.setAttribute('data-theme', savedTheme);
+ const btn = document.getElementById('theme-btn');
+ if (btn) {
+ btn.innerHTML = savedTheme === 'dark' ? '☼' : '☾';
+ }
+ }
+}
+
+// ============================================================================
+// Utility Functions
+// ============================================================================
+
+function createElement(tag, className, textContent = '') {
+ const el = document.createElement(tag);
+ if (className) el.className = className;
+ if (textContent) el.textContent = textContent;
+ return el;
+}
+
+function calculateMenuPosition(buttonRect, menuWidth, menuHeight) {
+ const viewport = { width: window.innerWidth, height: window.innerHeight };
+ const scroll = {
+ x: window.pageXOffset || document.documentElement.scrollLeft,
+ y: window.pageYOffset || document.documentElement.scrollTop
+ };
+
+ const left = buttonRect.right + menuWidth + 10 < viewport.width
+ ? buttonRect.right + scroll.x + 10
+ : Math.max(scroll.x + 10, buttonRect.left + scroll.x - menuWidth - 10);
+
+ const top = buttonRect.bottom + menuHeight + 10 < viewport.height
+ ? buttonRect.bottom + scroll.y + 5
+ : Math.max(scroll.y + 10, buttonRect.top + scroll.y - menuHeight - 10);
+
+ return { left, top };
+}
+
+// ============================================================================
+// Menu Management
+// ============================================================================
+
+function closeMenu() {
+ if (currentMenu) {
+ currentMenu.remove();
+ currentMenu = null;
+ }
+}
+
+function showNavigationMenu(button, items, title) {
+ closeMenu();
+
+ const menu = createElement('div', 'callee-menu');
+ menu.appendChild(createElement('div', 'callee-menu-header', title));
+
+ items.forEach(linkData => {
+ const item = createElement('div', 'callee-menu-item');
+
+ const funcDiv = createElement('div', 'callee-menu-func');
+ funcDiv.textContent = linkData.func;
+
+ if (linkData.count !== undefined && linkData.count > 0) {
+ const countBadge = createElement('span', 'count-badge');
+ countBadge.textContent = linkData.count.toLocaleString();
+ countBadge.title = `${linkData.count.toLocaleString()} samples`;
+ funcDiv.appendChild(document.createTextNode(' '));
+ funcDiv.appendChild(countBadge);
+ }
+
+ item.appendChild(funcDiv);
+ item.appendChild(createElement('div', 'callee-menu-file', linkData.file));
+ item.addEventListener('click', () => window.location.href = linkData.link);
+ menu.appendChild(item);
+ });
+
+ const pos = calculateMenuPosition(button.getBoundingClientRect(), 350, 300);
+ menu.style.left = `${pos.left}px`;
+ menu.style.top = `${pos.top}px`;
+
+ document.body.appendChild(menu);
+ currentMenu = menu;
+}
+
+// ============================================================================
+// Navigation
+// ============================================================================
+
+function handleNavigationClick(button, e) {
+ e.stopPropagation();
+
+ const navData = button.getAttribute('data-nav');
+ if (navData) {
+ window.location.href = JSON.parse(navData).link;
+ return;
+ }
+
+ const navMulti = button.getAttribute('data-nav-multi');
+ if (navMulti) {
+ const items = JSON.parse(navMulti);
+ const title = button.classList.contains('caller') ? 'Choose a caller:' : 'Choose a callee:';
+ showNavigationMenu(button, items, title);
+ }
+}
+
+function scrollToTargetLine() {
+ if (!window.location.hash) return;
+ const target = document.querySelector(window.location.hash);
+ if (target) {
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+}
+
+// ============================================================================
+// Sample Count & Intensity
+// ============================================================================
+
+function getSampleCount(line) {
+ let text;
+ if (colorMode === 'self') {
+ text = line.querySelector('.line-samples-self')?.textContent.trim().replace(/,/g, '');
+ } else {
+ text = line.querySelector('.line-samples-cumulative')?.textContent.trim().replace(/,/g, '');
+ }
+ return parseInt(text) || 0;
+}
+
+function getIntensityClass(ratio) {
+ if (ratio > 0.75) return 'vhot';
+ if (ratio > 0.5) return 'hot';
+ if (ratio > 0.25) return 'warm';
+ return 'cold';
+}
+
+// ============================================================================
+// Scroll Minimap
+// ============================================================================
+
+function buildScrollMarker() {
+ const existing = document.getElementById('scroll_marker');
+ if (existing) existing.remove();
+
+ if (document.body.scrollHeight <= window.innerHeight) return;
+
+ const allLines = document.querySelectorAll('.code-line');
+ const lines = Array.from(allLines).filter(line => line.style.display !== 'none');
+ const markerScale = window.innerHeight / document.body.scrollHeight;
+ const lineHeight = Math.min(Math.max(3, window.innerHeight / lines.length), 10);
+ const maxSamples = Math.max(...Array.from(lines, getSampleCount));
+
+ const scrollMarker = createElement('div', '');
+ scrollMarker.id = 'scroll_marker';
+
+ let prevLine = -99, lastMark, lastTop;
+
+ lines.forEach((line, index) => {
+ const samples = getSampleCount(line);
+ if (samples === 0) return;
+
+ const lineTop = Math.floor(line.offsetTop * markerScale);
+ const lineNumber = index + 1;
+ const intensityClass = maxSamples > 0 ? getIntensityClass(samples / maxSamples) : 'cold';
+
+ if (lineNumber === prevLine + 1 && lastMark?.classList.contains(intensityClass)) {
+ lastMark.style.height = `${lineTop + lineHeight - lastTop}px`;
+ } else {
+ lastMark = createElement('div', `marker ${intensityClass}`);
+ lastMark.style.height = `${lineHeight}px`;
+ lastMark.style.top = `${lineTop}px`;
+ scrollMarker.appendChild(lastMark);
+ lastTop = lineTop;
+ }
+
+ prevLine = lineNumber;
+ });
+
+ document.body.appendChild(scrollMarker);
+}
+
+// ============================================================================
+// Toggle Controls
+// ============================================================================
+
+function updateToggleUI(toggleId, isOn) {
+ const toggle = document.getElementById(toggleId);
+ if (toggle) {
+ const track = toggle.querySelector('.toggle-track');
+ const labels = toggle.querySelectorAll('.toggle-label');
+ if (isOn) {
+ track.classList.add('on');
+ labels[0].classList.remove('active');
+ labels[1].classList.add('active');
+ } else {
+ track.classList.remove('on');
+ labels[0].classList.add('active');
+ labels[1].classList.remove('active');
+ }
+ }
+}
+
+function toggleColdCode() {
+ coldCodeHidden = !coldCodeHidden;
+ applyHotFilter();
+ updateToggleUI('toggle-cold', coldCodeHidden);
+ buildScrollMarker();
+}
+
+function applyHotFilter() {
+ const lines = document.querySelectorAll('.code-line');
+
+ lines.forEach(line => {
+ const selfSamples = line.querySelector('.line-samples-self')?.textContent.trim();
+ const cumulativeSamples = line.querySelector('.line-samples-cumulative')?.textContent.trim();
+
+ let isCold;
+ if (colorMode === 'self') {
+ isCold = !selfSamples || selfSamples === '';
+ } else {
+ isCold = !cumulativeSamples || cumulativeSamples === '';
+ }
+
+ if (isCold) {
+ line.style.display = coldCodeHidden ? 'none' : 'flex';
+ } else {
+ line.style.display = 'flex';
+ }
+ });
+}
+
+function toggleColorMode() {
+ colorMode = colorMode === 'self' ? 'cumulative' : 'self';
+ const lines = document.querySelectorAll('.code-line');
+
+ lines.forEach(line => {
+ let bgColor;
+ if (colorMode === 'self') {
+ bgColor = line.getAttribute('data-self-color');
+ } else {
+ bgColor = line.getAttribute('data-cumulative-color');
+ }
+
+ if (bgColor) {
+ line.style.background = bgColor;
+ }
+ });
+
+ updateToggleUI('toggle-color-mode', colorMode === 'cumulative');
+
+ if (coldCodeHidden) {
+ applyHotFilter();
+ }
+
+ buildScrollMarker();
+}
+
+// ============================================================================
+// Initialization
+// ============================================================================
+
+document.addEventListener('DOMContentLoaded', function() {
+ // Restore UI state (theme, etc.)
+ restoreUIState();
+
+ // Apply background colors
+ document.querySelectorAll('.code-line[data-bg-color]').forEach(line => {
+ const bgColor = line.getAttribute('data-bg-color');
+ if (bgColor) {
+ line.style.background = bgColor;
+ }
+ });
+
+ // Initialize navigation buttons
+ document.querySelectorAll('.nav-btn').forEach(button => {
+ button.addEventListener('click', e => handleNavigationClick(button, e));
+ });
+
+ // Initialize line number permalink handlers
+ document.querySelectorAll('.line-number').forEach(lineNum => {
+ lineNum.style.cursor = 'pointer';
+ lineNum.addEventListener('click', e => {
+ window.location.hash = `line-${e.target.textContent.trim()}`;
+ });
+ });
+
+ // Initialize toggle buttons
+ const toggleColdBtn = document.getElementById('toggle-cold');
+ if (toggleColdBtn) {
+ toggleColdBtn.addEventListener('click', toggleColdCode);
+ }
+
+ const colorModeBtn = document.getElementById('toggle-color-mode');
+ if (colorModeBtn) {
+ colorModeBtn.addEventListener('click', toggleColorMode);
+ }
+
+ // Build scroll marker
+ setTimeout(buildScrollMarker, 200);
+
+ // Setup scroll-to-line behavior
+ setTimeout(scrollToTargetLine, 100);
+});
+
+// Close menu when clicking outside
+document.addEventListener('click', e => {
+ if (currentMenu && !currentMenu.contains(e.target) && !e.target.classList.contains('nav-btn')) {
+ closeMenu();
+ }
+});
+
+// Handle hash changes
+window.addEventListener('hashchange', () => setTimeout(scrollToTargetLine, 50));
+
+// Rebuild scroll marker on resize
+window.addEventListener('resize', buildScrollMarker);
--- /dev/null
+// Tachyon Profiler - Heatmap Index JavaScript
+// Index page specific functionality
+
+// ============================================================================
+// Theme Support
+// ============================================================================
+
+function toggleTheme() {
+ const html = document.documentElement;
+ const current = html.getAttribute('data-theme') || 'light';
+ const next = current === 'light' ? 'dark' : 'light';
+ html.setAttribute('data-theme', next);
+ localStorage.setItem('heatmap-theme', next);
+
+ // Update theme button icon
+ const btn = document.getElementById('theme-btn');
+ if (btn) {
+ btn.innerHTML = next === 'dark' ? '☼' : '☾'; // sun or moon
+ }
+}
+
+function restoreUIState() {
+ // Restore theme
+ const savedTheme = localStorage.getItem('heatmap-theme');
+ if (savedTheme) {
+ document.documentElement.setAttribute('data-theme', savedTheme);
+ const btn = document.getElementById('theme-btn');
+ if (btn) {
+ btn.innerHTML = savedTheme === 'dark' ? '☼' : '☾';
+ }
+ }
+}
+
+// ============================================================================
+// Type Section Toggle (stdlib, project, etc)
+// ============================================================================
+
+function toggleTypeSection(header) {
+ const section = header.parentElement;
+ const content = section.querySelector('.type-content');
+ const icon = header.querySelector('.type-icon');
+
+ if (content.style.display === 'none') {
+ content.style.display = 'block';
+ icon.textContent = '\u25BC';
+ } else {
+ content.style.display = 'none';
+ icon.textContent = '\u25B6';
+ }
+}
+
+// ============================================================================
+// Folder Toggle
+// ============================================================================
+
+function toggleFolder(header) {
+ const folder = header.parentElement;
+ const content = folder.querySelector('.folder-content');
+ const icon = header.querySelector('.folder-icon');
+
+ if (content.style.display === 'none') {
+ content.style.display = 'block';
+ icon.textContent = '\u25BC';
+ folder.classList.remove('collapsed');
+ } else {
+ content.style.display = 'none';
+ icon.textContent = '\u25B6';
+ folder.classList.add('collapsed');
+ }
+}
+
+// ============================================================================
+// Expand/Collapse All
+// ============================================================================
+
+function expandAll() {
+ // Expand all type sections
+ document.querySelectorAll('.type-section').forEach(section => {
+ const content = section.querySelector('.type-content');
+ const icon = section.querySelector('.type-icon');
+ content.style.display = 'block';
+ icon.textContent = '\u25BC';
+ });
+
+ // Expand all folders
+ document.querySelectorAll('.folder-node').forEach(folder => {
+ const content = folder.querySelector('.folder-content');
+ const icon = folder.querySelector('.folder-icon');
+ content.style.display = 'block';
+ icon.textContent = '\u25BC';
+ folder.classList.remove('collapsed');
+ });
+}
+
+function collapseAll() {
+ document.querySelectorAll('.folder-node').forEach(folder => {
+ const content = folder.querySelector('.folder-content');
+ const icon = folder.querySelector('.folder-icon');
+ content.style.display = 'none';
+ icon.textContent = '\u25B6';
+ folder.classList.add('collapsed');
+ });
+}
+
+// ============================================================================
+// Initialization
+// ============================================================================
+
+document.addEventListener('DOMContentLoaded', function() {
+ restoreUIState();
+});
--- /dev/null
+<!doctype html>
+<html lang="en" data-theme="light">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Tachyon Profiler - Heatmap Report</title>
+ <!-- INLINE_CSS -->
+</head>
+<body>
+ <div class="app-layout">
+ <!-- Top Bar -->
+ <header class="top-bar">
+ <div class="brand">
+ <span class="brand-text">Tachyon</span>
+ <span class="brand-divider"></span>
+ <span class="brand-subtitle">Heatmap Report</span>
+ </div>
+ <div class="toolbar">
+ <button
+ class="toolbar-btn theme-toggle"
+ onclick="toggleTheme()"
+ title="Toggle theme"
+ id="theme-btn"
+ >☾</button>
+ </div>
+ </header>
+
+ <!-- Main Content -->
+ <div class="main-content">
+ <!-- Stats Summary -->
+ <div class="stats-summary">
+ <div class="stat-card">
+ <div class="stat-icon">📄</div>
+ <div class="stat-data">
+ <span class="stat-value"><!-- NUM_FILES --></span>
+ <span class="stat-label">Files Profiled</span>
+ </div>
+ <div class="stat-sparkline"></div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-icon">📊</div>
+ <div class="stat-data">
+ <span class="stat-value"><!-- TOTAL_SAMPLES --></span>
+ <span class="stat-label">Total Snapshots</span>
+ </div>
+ <div class="stat-sparkline"></div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-icon">⏱</div>
+ <div class="stat-data">
+ <span class="stat-value"><!-- DURATION --></span>
+ <span class="stat-label">Duration</span>
+ </div>
+ <div class="stat-sparkline"></div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-icon">⚡</div>
+ <div class="stat-data">
+ <span class="stat-value"><!-- SAMPLE_RATE --></span>
+ <span class="stat-label">Samples/sec</span>
+ </div>
+ <div class="stat-sparkline"></div>
+ </div>
+ <div class="rate-card">
+ <div class="rate-header">
+ <div class="rate-info">
+ <div class="rate-icon">⚠</div>
+ <span class="rate-label">Error Rate</span>
+ </div>
+ <span class="rate-value"><!-- ERROR_RATE --></span>
+ </div>
+ <div class="rate-bar">
+ <div class="rate-fill <!-- ERROR_RATE_CLASS -->" style="width: <!-- ERROR_RATE_WIDTH -->%;"></div>
+ </div>
+ </div>
+ <div class="rate-card">
+ <div class="rate-header">
+ <div class="rate-info">
+ <div class="rate-icon">💥</div>
+ <span class="rate-label">Missed Samples</span>
+ </div>
+ <span class="rate-value"><!-- MISSED_SAMPLES --></span>
+ </div>
+ <div class="rate-bar">
+ <div class="rate-fill <!-- MISSED_SAMPLES_CLASS -->" style="width: <!-- MISSED_SAMPLES_WIDTH -->%;"></div>
+ </div>
+ </div>
+ </div>
+
+ <!-- File List Section -->
+ <div class="section-header">
+ <h2 class="section-title">Profiled Files</h2>
+ </div>
+
+ <div class="filter-controls">
+ <button onclick="expandAll()" class="control-btn">Expand All</button>
+ <button onclick="collapseAll()" class="control-btn">Collapse All</button>
+ </div>
+
+ <div class="module-sections">
+<!-- SECTIONS_HTML -->
+ </div>
+ </div>
+
+ <!-- Status Bar -->
+ <footer class="status-bar">
+ <span class="status-item">
+ <span class="status-value">Tachyon Profiler</span>
+ </span>
+ <span class="status-item">
+ <span class="status-label">Python Sampling Profiler</span>
+ </span>
+ </footer>
+ </div>
+
+ <!-- INLINE_JS -->
+</body>
+</html>
--- /dev/null
+<!doctype html>
+<html lang="en" data-theme="light">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title><!-- FILENAME --> - Heatmap</title>
+ <!-- INLINE_CSS -->
+</head>
+<body class="code-view">
+ <div class="app-layout">
+ <!-- Top Bar (Code Header) -->
+ <header class="top-bar">
+ <div class="brand">
+ <span class="brand-text">Tachyon</span>
+ <span class="brand-divider"></span>
+ <span class="brand-subtitle" style="font-family: var(--font-mono); font-size: 13px;"><!-- FILENAME --></span>
+ </div>
+ <div class="toolbar">
+ <a href="index.html" class="back-link">Back to Index</a>
+ <button
+ class="toolbar-btn theme-toggle"
+ onclick="toggleTheme()"
+ title="Toggle theme"
+ id="theme-btn"
+ >☾</button>
+ </div>
+ </header>
+
+ <!-- File Stats Bar -->
+ <div class="file-stats">
+ <div class="stats-grid">
+ <div class="stat-item">
+ <div class="stat-value"><!-- TOTAL_SELF_SAMPLES --></div>
+ <div class="stat-label">Self Samples</div>
+ </div>
+ <div class="stat-item">
+ <div class="stat-value"><!-- TOTAL_SAMPLES --></div>
+ <div class="stat-label">Cumulative</div>
+ </div>
+ <div class="stat-item">
+ <div class="stat-value"><!-- NUM_LINES --></div>
+ <div class="stat-label">Lines Hit</div>
+ </div>
+ <div class="stat-item">
+ <div class="stat-value"><!-- PERCENTAGE -->%</div>
+ <div class="stat-label">% of Total</div>
+ </div>
+ <div class="stat-item">
+ <div class="stat-value"><!-- MAX_SELF_SAMPLES --></div>
+ <div class="stat-label">Max Self</div>
+ </div>
+ <div class="stat-item">
+ <div class="stat-value"><!-- MAX_SAMPLES --></div>
+ <div class="stat-label">Max Total</div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Legend -->
+ <div class="legend">
+ <div class="legend-content">
+ <span class="legend-title">Intensity:</span>
+ <div class="legend-gradient"></div>
+ <div class="legend-labels">
+ <span>Cold</span>
+ <span>→</span>
+ <span>Hot</span>
+ </div>
+ <div class="toggle-switch" id="toggle-color-mode" title="Toggle between self time and total time coloring">
+ <span class="toggle-label active">Self Time</span>
+ <div class="toggle-track"></div>
+ <span class="toggle-label">Total Time</span>
+ </div>
+ <div class="toggle-switch" id="toggle-cold" title="Toggle visibility of lines with zero samples">
+ <span class="toggle-label active">Show All</span>
+ <div class="toggle-track"></div>
+ <span class="toggle-label">Hot Only</span>
+ </div>
+ </div>
+ </div>
+
+ <!-- Code Container -->
+ <div class="code-container">
+ <div class="code-header-row">
+ <div class="header-line-number">Line</div>
+ <div class="header-samples-self">Self</div>
+ <div class="header-samples-cumulative">Total</div>
+ <div class="header-content">Code</div>
+ </div>
+<!-- CODE_LINES -->
+ </div>
+ </div>
+
+ <!-- INLINE_JS -->
+</body>
+</html>
--- /dev/null
+/* ==========================================================================
+ Python Profiler - Shared CSS Foundation
+ Design system shared between Flamegraph and Heatmap viewers
+ ========================================================================== */
+
+/* --------------------------------------------------------------------------
+ CSS Variables & Theme System
+ -------------------------------------------------------------------------- */
+
+:root {
+ /* Typography */
+ --font-sans: "Source Sans Pro", "Lucida Grande", "Lucida Sans Unicode",
+ "Geneva", "Verdana", sans-serif;
+ --font-mono: 'SF Mono', 'Monaco', 'Consolas', 'Liberation Mono', monospace;
+
+ /* Python brand colors (theme-independent) */
+ --python-blue: #3776ab;
+ --python-blue-light: #4584bb;
+ --python-blue-lighter: #5592cc;
+ --python-gold: #ffd43b;
+ --python-gold-dark: #ffcd02;
+ --python-gold-light: #ffdc5c;
+
+ /* Heat palette - defined per theme below */
+
+ /* Layout */
+ --sidebar-width: 280px;
+ --sidebar-collapsed: 44px;
+ --topbar-height: 56px;
+ --statusbar-height: 32px;
+
+ /* Transitions */
+ --transition-fast: 0.15s ease;
+ --transition-normal: 0.25s ease;
+}
+
+/* Light theme (default) */
+:root, [data-theme="light"] {
+ --bg-primary: #ffffff;
+ --bg-secondary: #f8f9fa;
+ --bg-tertiary: #e9ecef;
+ --border: #e9ecef;
+ --border-subtle: #f0f2f5;
+
+ --text-primary: #2e3338;
+ --text-secondary: #5a6c7d;
+ --text-muted: #8b949e;
+
+ --accent: #3776ab;
+ --accent-hover: #2d5aa0;
+ --accent-glow: rgba(55, 118, 171, 0.15);
+
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
+ --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.15);
+
+ --header-gradient: linear-gradient(135deg, #3776ab 0%, #4584bb 100%);
+
+ /* Light mode heat palette - blue to yellow to orange to red (cold to hot) */
+ --heat-1: #d6e9f8;
+ --heat-2: #a8d0ef;
+ --heat-3: #7ba3d1;
+ --heat-4: #ffe6a8;
+ --heat-5: #ffd43b;
+ --heat-6: #ffb84d;
+ --heat-7: #ff9966;
+ --heat-8: #ff6347;
+
+ /* Code view specific */
+ --code-bg: #ffffff;
+ --code-bg-line: #f8f9fa;
+ --code-border: #e9ecef;
+ --code-text: #2e3338;
+ --code-text-muted: #8b949e;
+ --code-accent: #3776ab;
+
+ /* Navigation colors */
+ --nav-caller: #2563eb;
+ --nav-caller-hover: #1d4ed8;
+ --nav-callee: #dc2626;
+ --nav-callee-hover: #b91c1c;
+}
+
+/* Dark theme */
+[data-theme="dark"] {
+ --bg-primary: #0d1117;
+ --bg-secondary: #161b22;
+ --bg-tertiary: #21262d;
+ --border: #30363d;
+ --border-subtle: #21262d;
+
+ --text-primary: #e6edf3;
+ --text-secondary: #8b949e;
+ --text-muted: #6e7681;
+
+ --accent: #58a6ff;
+ --accent-hover: #79b8ff;
+ --accent-glow: rgba(88, 166, 255, 0.15);
+
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
+ --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
+
+ --header-gradient: linear-gradient(135deg, #21262d 0%, #30363d 100%);
+
+ /* Dark mode heat palette - dark blue to teal to yellow to orange (cold to hot) */
+ --heat-1: #1e3a5f;
+ --heat-2: #2d5580;
+ --heat-3: #4a7ba7;
+ --heat-4: #5a9fa8;
+ --heat-5: #7ec488;
+ --heat-6: #c4de6a;
+ --heat-7: #f4d44d;
+ --heat-8: #ff6b35;
+
+ /* Code view specific - dark mode */
+ --code-bg: #0d1117;
+ --code-bg-line: #161b22;
+ --code-border: #30363d;
+ --code-text: #e6edf3;
+ --code-text-muted: #6e7681;
+ --code-accent: #58a6ff;
+
+ /* Navigation colors - dark theme friendly */
+ --nav-caller: #58a6ff;
+ --nav-caller-hover: #4184e4;
+ --nav-callee: #f87171;
+ --nav-callee-hover: #e53e3e;
+}
+
+/* --------------------------------------------------------------------------
+ Base Styles
+ -------------------------------------------------------------------------- */
+
+*, *::before, *::after {
+ box-sizing: border-box;
+}
+
+html, body {
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: var(--font-sans);
+ font-size: 14px;
+ line-height: 1.6;
+ color: var(--text-primary);
+ background: var(--bg-primary);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ transition: background var(--transition-normal), color var(--transition-normal);
+}
+
+/* --------------------------------------------------------------------------
+ Layout Structure
+ -------------------------------------------------------------------------- */
+
+.app-layout {
+ display: flex;
+ flex-direction: column;
+}
+
+/* --------------------------------------------------------------------------
+ Top Bar
+ -------------------------------------------------------------------------- */
+
+.top-bar {
+ height: var(--topbar-height);
+ background: var(--header-gradient);
+ display: flex;
+ align-items: center;
+ padding: 0 16px;
+ gap: 16px;
+ flex-shrink: 0;
+ box-shadow: 0 2px 10px rgba(55, 118, 171, 0.25);
+ border-bottom: 2px solid var(--python-gold);
+}
+
+/* Brand / Logo */
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ color: white;
+ text-decoration: none;
+ flex-shrink: 0;
+}
+
+.brand-logo {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ flex-shrink: 0;
+}
+
+/* Style the inlined SVG/img inside brand-logo */
+.brand-logo svg,
+.brand-logo img {
+ width: 28px;
+ height: 28px;
+ display: block;
+ object-fit: contain;
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
+}
+
+.brand-info {
+ display: flex;
+ flex-direction: column;
+ line-height: 1.15;
+}
+
+.brand-text {
+ font-weight: 700;
+ font-size: 16px;
+ letter-spacing: -0.3px;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
+}
+
+.brand-subtitle {
+ font-weight: 500;
+ font-size: 10px;
+ opacity: 0.9;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.brand-divider {
+ width: 1px;
+ height: 16px;
+ background: rgba(255, 255, 255, 0.3);
+}
+
+/* Toolbar */
+.toolbar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-left: auto;
+}
+
+.toolbar-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ font-size: 15px;
+ color: white;
+ background: rgba(255, 255, 255, 0.12);
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.toolbar-btn:hover {
+ background: rgba(255, 255, 255, 0.22);
+ border-color: rgba(255, 255, 255, 0.35);
+}
+
+.toolbar-btn:active {
+ transform: scale(0.95);
+}
+
+/* --------------------------------------------------------------------------
+ Status Bar
+ -------------------------------------------------------------------------- */
+
+.status-bar {
+ height: var(--statusbar-height);
+ background: var(--bg-secondary);
+ border-top: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ padding: 0 16px;
+ gap: 16px;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--text-secondary);
+ flex-shrink: 0;
+}
+
+.status-item {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.status-item::before {
+ content: '';
+ width: 4px;
+ height: 4px;
+ background: var(--python-gold);
+ border-radius: 50%;
+}
+
+.status-item:first-child::before {
+ display: none;
+}
+
+.status-label {
+ color: var(--text-muted);
+}
+
+.status-value {
+ color: var(--text-primary);
+ font-weight: 500;
+}
+
+.status-value.accent {
+ color: var(--accent);
+ font-weight: 600;
+}
+
+/* --------------------------------------------------------------------------
+ Animations
+ -------------------------------------------------------------------------- */
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(12px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes shimmer {
+ 0% { left: -100%; }
+ 100% { left: 100%; }
+}
+
+/* --------------------------------------------------------------------------
+ Focus States (Accessibility)
+ -------------------------------------------------------------------------- */
+
+button:focus-visible,
+select:focus-visible,
+input:focus-visible {
+ outline: 2px solid var(--python-gold);
+ outline-offset: 2px;
+}
+
+/* --------------------------------------------------------------------------
+ Shared Responsive
+ -------------------------------------------------------------------------- */
+
+@media (max-width: 900px) {
+ .brand-subtitle {
+ display: none;
+ }
+}
+
+@media (max-width: 600px) {
+ .toolbar-btn:not(.theme-toggle) {
+ display: none;
+ }
+}
from .sample import sample, sample_live
from .pstats_collector import PstatsCollector
from .stack_collector import CollapsedStackCollector, FlamegraphCollector
+from .heatmap_collector import HeatmapCollector
from .gecko_collector import GeckoCollector
from .constants import (
PROFILING_MODE_ALL,
"collapsed": "txt",
"flamegraph": "html",
"gecko": "json",
+ "heatmap": "html",
}
COLLECTOR_MAP = {
"collapsed": CollapsedStackCollector,
"flamegraph": FlamegraphCollector,
"gecko": GeckoCollector,
+ "heatmap": HeatmapCollector,
}
dest="format",
help="Generate Gecko format for Firefox Profiler",
)
+ format_group.add_argument(
+ "--heatmap",
+ action="store_const",
+ const="heatmap",
+ dest="format",
+ help="Generate interactive HTML heatmap visualization with line-level sample counts",
+ )
parser.set_defaults(format="pstats")
output_group.add_argument(
"-o",
"--output",
dest="outfile",
- help="Save output to a file (default: stdout for pstats, "
- "auto-generated filename for other formats)",
+ help="Output path (default: stdout for pstats, auto-generated for others). "
+ "For heatmap: directory name (default: heatmap_PID)",
)
Generated filename
"""
extension = FORMAT_EXTENSIONS.get(format_type, "txt")
+ # For heatmap, use cleaner directory name without extension
+ if format_type == "heatmap":
+ return f"heatmap_{pid}"
return f"{format_type}.{pid}.{extension}"
--- /dev/null
+"""Heatmap collector for Python profiling with line-level execution heat visualization."""
+
+import base64
+import collections
+import html
+import importlib.resources
+import json
+import os
+import platform
+import site
+import sys
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Dict, List, Tuple, Optional, Any
+
+from ._css_utils import get_combined_css
+from .stack_collector import StackTraceCollector
+
+
+# ============================================================================
+# Data Classes
+# ============================================================================
+
+@dataclass
+class FileStats:
+ """Statistics for a single profiled file."""
+ filename: str
+ module_name: str
+ module_type: str
+ total_samples: int
+ total_self_samples: int
+ num_lines: int
+ max_samples: int
+ max_self_samples: int
+ percentage: float = 0.0
+
+
+@dataclass
+class TreeNode:
+ """Node in the hierarchical file tree structure."""
+ files: List[FileStats] = field(default_factory=list)
+ samples: int = 0
+ count: int = 0
+ children: Dict[str, 'TreeNode'] = field(default_factory=dict)
+
+
+@dataclass
+class ColorGradient:
+ """Configuration for heatmap color gradient calculations."""
+ # Color stops thresholds
+ stop_1: float = 0.2 # Blue to cyan transition
+ stop_2: float = 0.4 # Cyan to green transition
+ stop_3: float = 0.6 # Green to yellow transition
+ stop_4: float = 0.8 # Yellow to orange transition
+ stop_5: float = 1.0 # Orange to red transition
+
+ # Alpha (opacity) values
+ alpha_very_cold: float = 0.3
+ alpha_cold: float = 0.4
+ alpha_medium: float = 0.5
+ alpha_warm: float = 0.6
+ alpha_hot_base: float = 0.7
+ alpha_hot_range: float = 0.15
+
+ # Gradient multiplier
+ multiplier: int = 5
+
+ # Cache for calculated colors
+ cache: Dict[float, Tuple[int, int, int, float]] = field(default_factory=dict)
+
+
+# ============================================================================
+# Module Path Analysis
+# ============================================================================
+
+def get_python_path_info():
+ """Get information about Python installation paths for module extraction.
+
+ Returns:
+ dict: Dictionary containing stdlib path, site-packages paths, and sys.path entries.
+ """
+ info = {
+ 'stdlib': None,
+ 'site_packages': [],
+ 'sys_path': []
+ }
+
+ # Get standard library path from os module location
+ try:
+ if hasattr(os, '__file__') and os.__file__:
+ info['stdlib'] = Path(os.__file__).parent
+ except (AttributeError, OSError):
+ pass # Silently continue if we can't determine stdlib path
+
+ # Get site-packages directories
+ site_packages = []
+ try:
+ site_packages.extend(Path(p) for p in site.getsitepackages())
+ except (AttributeError, OSError):
+ pass # Continue without site packages if unavailable
+
+ # Get user site-packages
+ try:
+ user_site = site.getusersitepackages()
+ if user_site and Path(user_site).exists():
+ site_packages.append(Path(user_site))
+ except (AttributeError, OSError):
+ pass # Continue without user site packages
+
+ info['site_packages'] = site_packages
+ info['sys_path'] = [Path(p) for p in sys.path if p]
+
+ return info
+
+
+def extract_module_name(filename, path_info):
+ """Extract Python module name and type from file path.
+
+ Args:
+ filename: Path to the Python file
+ path_info: Dictionary from get_python_path_info()
+
+ Returns:
+ tuple: (module_name, module_type) where module_type is one of:
+ 'stdlib', 'site-packages', 'project', or 'other'
+ """
+ if not filename:
+ return ('unknown', 'other')
+
+ try:
+ file_path = Path(filename)
+ except (ValueError, OSError):
+ return (str(filename), 'other')
+
+ # Check if it's in stdlib
+ if path_info['stdlib'] and _is_subpath(file_path, path_info['stdlib']):
+ try:
+ rel_path = file_path.relative_to(path_info['stdlib'])
+ return (_path_to_module(rel_path), 'stdlib')
+ except ValueError:
+ pass
+
+ # Check site-packages
+ for site_pkg in path_info['site_packages']:
+ if _is_subpath(file_path, site_pkg):
+ try:
+ rel_path = file_path.relative_to(site_pkg)
+ return (_path_to_module(rel_path), 'site-packages')
+ except ValueError:
+ continue
+
+ # Check other sys.path entries (project files)
+ if not str(file_path).startswith(('<', '[')): # Skip special files
+ for path_entry in path_info['sys_path']:
+ if _is_subpath(file_path, path_entry):
+ try:
+ rel_path = file_path.relative_to(path_entry)
+ return (_path_to_module(rel_path), 'project')
+ except ValueError:
+ continue
+
+ # Fallback: just use the filename
+ return (_path_to_module(file_path), 'other')
+
+
+def _is_subpath(file_path, parent_path):
+ try:
+ file_path.relative_to(parent_path)
+ return True
+ except (ValueError, OSError):
+ return False
+
+
+def _path_to_module(path):
+ if isinstance(path, str):
+ path = Path(path)
+
+ # Remove .py extension
+ if path.suffix == '.py':
+ path = path.with_suffix('')
+
+ # Convert path separators to dots
+ parts = path.parts
+
+ # Handle __init__ files - they represent the package itself
+ if parts and parts[-1] == '__init__':
+ parts = parts[:-1]
+
+ return '.'.join(parts) if parts else path.stem
+
+
+# ============================================================================
+# Helper Classes
+# ============================================================================
+
+class _TemplateLoader:
+ """Loads and caches HTML/CSS/JS templates for heatmap generation."""
+
+ def __init__(self):
+ """Load all templates and assets once."""
+ self.index_template = None
+ self.file_template = None
+ self.index_css = None
+ self.index_js = None
+ self.file_css = None
+ self.file_js = None
+ self.logo_html = None
+
+ self._load_templates()
+
+ def _load_templates(self):
+ """Load all template files from _heatmap_assets."""
+ try:
+ template_dir = importlib.resources.files(__package__)
+ assets_dir = template_dir / "_heatmap_assets"
+
+ # Load HTML templates
+ self.index_template = (assets_dir / "heatmap_index_template.html").read_text(encoding="utf-8")
+ self.file_template = (assets_dir / "heatmap_pyfile_template.html").read_text(encoding="utf-8")
+
+ # Load CSS (same file used for both index and file pages)
+ css_content = get_combined_css("heatmap")
+ self.index_css = css_content
+ self.file_css = css_content
+
+ # Load JS
+ self.index_js = (assets_dir / "heatmap_index.js").read_text(encoding="utf-8")
+ self.file_js = (assets_dir / "heatmap.js").read_text(encoding="utf-8")
+
+ # Load Python logo
+ logo_dir = template_dir / "_assets"
+ try:
+ png_path = logo_dir / "python-logo-only.png"
+ b64_logo = base64.b64encode(png_path.read_bytes()).decode("ascii")
+ self.logo_html = f'<img src="data:image/png;base64,{b64_logo}" alt="Python logo" class="python-logo"/>'
+ except (FileNotFoundError, IOError) as e:
+ self.logo_html = '<div class="python-logo-placeholder"></div>'
+ print(f"Warning: Could not load Python logo: {e}")
+
+ except (FileNotFoundError, IOError) as e:
+ raise RuntimeError(f"Failed to load heatmap template files: {e}") from e
+
+
+class _TreeBuilder:
+ """Builds hierarchical tree structure from file statistics."""
+
+ @staticmethod
+ def build_file_tree(file_stats: List[FileStats]) -> Dict[str, TreeNode]:
+ """Build hierarchical tree grouped by module type, then by module structure.
+
+ Args:
+ file_stats: List of FileStats objects
+
+ Returns:
+ Dictionary mapping module types to their tree roots
+ """
+ # Group by module type first
+ type_groups = {'stdlib': [], 'site-packages': [], 'project': [], 'other': []}
+ for stat in file_stats:
+ type_groups[stat.module_type].append(stat)
+
+ # Build tree for each type
+ trees = {}
+ for module_type, stats in type_groups.items():
+ if not stats:
+ continue
+
+ root_node = TreeNode()
+
+ for stat in stats:
+ module_name = stat.module_name
+ parts = module_name.split('.')
+
+ # Navigate/create tree structure
+ current_node = root_node
+ for i, part in enumerate(parts):
+ if i == len(parts) - 1:
+ # Last part - store the file
+ current_node.files.append(stat)
+ else:
+ # Intermediate part - create or navigate
+ if part not in current_node.children:
+ current_node.children[part] = TreeNode()
+ current_node = current_node.children[part]
+
+ # Calculate aggregate stats for this type's tree
+ _TreeBuilder._calculate_node_stats(root_node)
+ trees[module_type] = root_node
+
+ return trees
+
+ @staticmethod
+ def _calculate_node_stats(node: TreeNode) -> Tuple[int, int]:
+ """Recursively calculate aggregate statistics for tree nodes.
+
+ Args:
+ node: TreeNode to calculate stats for
+
+ Returns:
+ Tuple of (total_samples, file_count)
+ """
+ total_samples = 0
+ file_count = 0
+
+ # Count files at this level
+ for file_stat in node.files:
+ total_samples += file_stat.total_samples
+ file_count += 1
+
+ # Recursively process children
+ for child in node.children.values():
+ child_samples, child_count = _TreeBuilder._calculate_node_stats(child)
+ total_samples += child_samples
+ file_count += child_count
+
+ node.samples = total_samples
+ node.count = file_count
+ return total_samples, file_count
+
+
+class _HtmlRenderer:
+ """Renders hierarchical tree structures as HTML."""
+
+ def __init__(self, file_index: Dict[str, str], color_gradient: ColorGradient,
+ calculate_intensity_color_func):
+ """Initialize renderer with file index and color calculation function.
+
+ Args:
+ file_index: Mapping from filenames to HTML file names
+ color_gradient: ColorGradient configuration
+ calculate_intensity_color_func: Function to calculate colors
+ """
+ self.file_index = file_index
+ self.color_gradient = color_gradient
+ self.calculate_intensity_color = calculate_intensity_color_func
+ self.heatmap_bar_height = 16
+
+ def render_hierarchical_html(self, trees: Dict[str, TreeNode]) -> str:
+ """Build hierarchical HTML with type sections and collapsible module folders.
+
+ Args:
+ trees: Dictionary mapping module types to tree roots
+
+ Returns:
+ Complete HTML string for all sections
+ """
+ type_names = {
+ 'stdlib': '📚 Standard Library',
+ 'site-packages': '📦 Site Packages',
+ 'project': '🏗️ Project Files',
+ 'other': '📄 Other Files'
+ }
+
+ sections = []
+ for module_type in ['project', 'stdlib', 'site-packages', 'other']:
+ if module_type not in trees:
+ continue
+
+ tree = trees[module_type]
+
+ # Project starts expanded, others start collapsed
+ is_collapsed = module_type in {'stdlib', 'site-packages', 'other'}
+ icon = '▶' if is_collapsed else '▼'
+ content_style = ' style="display: none;"' if is_collapsed else ''
+
+ section_html = f'''
+<div class="type-section">
+ <div class="type-header" onclick="toggleTypeSection(this)">
+ <span class="type-icon">{icon}</span>
+ <span class="type-title">{type_names[module_type]}</span>
+ <span class="type-stats">({tree.count} files, {tree.samples:,} samples)</span>
+ </div>
+ <div class="type-content"{content_style}>
+'''
+
+ # Render root folders
+ root_folders = sorted(tree.children.items(),
+ key=lambda x: x[1].samples, reverse=True)
+
+ for folder_name, folder_node in root_folders:
+ section_html += self._render_folder(folder_node, folder_name, level=1)
+
+ # Render root files (files not in any module)
+ if tree.files:
+ sorted_files = sorted(tree.files, key=lambda x: x.total_samples, reverse=True)
+ section_html += ' <div class="files-list">\n'
+ for stat in sorted_files:
+ section_html += self._render_file_item(stat, indent=' ')
+ section_html += ' </div>\n'
+
+ section_html += ' </div>\n</div>\n'
+ sections.append(section_html)
+
+ return '\n'.join(sections)
+
+ def _render_folder(self, node: TreeNode, name: str, level: int = 1) -> str:
+ """Render a single folder node recursively.
+
+ Args:
+ node: TreeNode to render
+ name: Display name for the folder
+ level: Nesting level for indentation
+
+ Returns:
+ HTML string for this folder and its contents
+ """
+ indent = ' ' * level
+ parts = []
+
+ # Render folder header (collapsed by default)
+ parts.append(f'{indent}<div class="folder-node collapsed" data-level="{level}">')
+ parts.append(f'{indent} <div class="folder-header" onclick="toggleFolder(this)">')
+ parts.append(f'{indent} <span class="folder-icon">▶</span>')
+ parts.append(f'{indent} <span class="folder-name">📁 {html.escape(name)}</span>')
+ parts.append(f'{indent} <span class="folder-stats">({node.count} files, {node.samples:,} samples)</span>')
+ parts.append(f'{indent} </div>')
+ parts.append(f'{indent} <div class="folder-content" style="display: none;">')
+
+ # Render sub-folders sorted by sample count
+ subfolders = sorted(node.children.items(),
+ key=lambda x: x[1].samples, reverse=True)
+
+ for subfolder_name, subfolder_node in subfolders:
+ parts.append(self._render_folder(subfolder_node, subfolder_name, level + 1))
+
+ # Render files in this folder
+ if node.files:
+ sorted_files = sorted(node.files, key=lambda x: x.total_samples, reverse=True)
+ parts.append(f'{indent} <div class="files-list">')
+ for stat in sorted_files:
+ parts.append(self._render_file_item(stat, indent=f'{indent} '))
+ parts.append(f'{indent} </div>')
+
+ parts.append(f'{indent} </div>')
+ parts.append(f'{indent}</div>')
+
+ return '\n'.join(parts)
+
+ def _render_file_item(self, stat: FileStats, indent: str = '') -> str:
+ """Render a single file item with heatmap bar.
+
+ Args:
+ stat: FileStats object
+ indent: Indentation string
+
+ Returns:
+ HTML string for file item
+ """
+ full_path = html.escape(stat.filename)
+ module_name = html.escape(stat.module_name)
+
+ intensity = stat.percentage / 100.0
+ r, g, b, alpha = self.calculate_intensity_color(intensity)
+ bg_color = f"rgba({r}, {g}, {b}, {alpha})"
+ bar_width = min(stat.percentage, 100)
+
+ html_file = self.file_index[stat.filename]
+
+ return (f'{indent}<div class="file-item">\n'
+ f'{indent} <a href="{html_file}" class="file-link" title="{full_path}">📄 {module_name}</a>\n'
+ f'{indent} <span class="file-samples">{stat.total_samples:,} samples</span>\n'
+ f'{indent} <div class="heatmap-bar-container"><div class="heatmap-bar" style="width: {bar_width}px; background-color: {bg_color}; height: {self.heatmap_bar_height}px;"></div></div>\n'
+ f'{indent}</div>\n')
+
+
+# ============================================================================
+# Main Collector Class
+# ============================================================================
+
+class HeatmapCollector(StackTraceCollector):
+ """Collector that generates coverage.py-style heatmap HTML output with line intensity.
+
+ This collector creates detailed HTML reports showing which lines of code
+ were executed most frequently during profiling, similar to coverage.py
+ but showing execution "heat" rather than just coverage.
+ """
+
+ # File naming and formatting constants
+ FILE_INDEX_FORMAT = "file_{:04d}.html"
+
+ def __init__(self, *args, **kwargs):
+ """Initialize the heatmap collector with data structures for analysis."""
+ super().__init__(*args, **kwargs)
+
+ # Sample counting data structures
+ self.line_samples = collections.Counter()
+ self.file_samples = collections.defaultdict(collections.Counter)
+ self.line_self_samples = collections.Counter()
+ self.file_self_samples = collections.defaultdict(collections.Counter)
+
+ # Call graph data structures for navigation
+ self.call_graph = collections.defaultdict(list)
+ self.callers_graph = collections.defaultdict(list)
+ self.function_definitions = {}
+
+ # Edge counting for call path analysis
+ self.edge_samples = collections.Counter()
+
+ # Statistics and metadata
+ self._total_samples = 0
+ self._path_info = get_python_path_info()
+ self.stats = {}
+
+ # Color gradient configuration
+ self._color_gradient = ColorGradient()
+
+ # Template loader (loads all templates once)
+ self._template_loader = _TemplateLoader()
+
+ # File index (populated during export)
+ self.file_index = {}
+
+ @property
+ def _color_cache(self):
+ """Compatibility property for accessing color cache."""
+ return self._color_gradient.cache
+
+ def set_stats(self, sample_interval_usec, duration_sec, sample_rate, error_rate=None, missed_samples=None, **kwargs):
+ """Set profiling statistics to include in heatmap output.
+
+ Args:
+ sample_interval_usec: Sampling interval in microseconds
+ duration_sec: Total profiling duration in seconds
+ sample_rate: Effective sampling rate
+ error_rate: Optional error rate during profiling
+ missed_samples: Optional percentage of missed samples
+ **kwargs: Additional statistics to include
+ """
+ self.stats = {
+ "sample_interval_usec": sample_interval_usec,
+ "duration_sec": duration_sec,
+ "sample_rate": sample_rate,
+ "error_rate": error_rate,
+ "missed_samples": missed_samples,
+ "python_version": sys.version,
+ "python_implementation": platform.python_implementation(),
+ "platform": platform.platform(),
+ }
+ self.stats.update(kwargs)
+
+ def process_frames(self, frames, thread_id):
+ """Process stack frames and count samples per line.
+
+ Args:
+ frames: List of frame tuples (filename, lineno, funcname)
+ frames[0] is the leaf (top of stack, where execution is)
+ thread_id: Thread ID for this stack trace
+ """
+ self._total_samples += 1
+
+ # Count each line in the stack and build call graph
+ for i, frame_info in enumerate(frames):
+ filename, lineno, funcname = frame_info
+
+ if not self._is_valid_frame(filename, lineno):
+ continue
+
+ # frames[0] is the leaf - where execution is actually happening
+ is_leaf = (i == 0)
+ self._record_line_sample(filename, lineno, funcname, is_leaf=is_leaf)
+
+ # Build call graph for adjacent frames
+ if i + 1 < len(frames):
+ self._record_call_relationship(frames[i], frames[i + 1])
+
+ def _is_valid_frame(self, filename, lineno):
+ """Check if a frame should be included in the heatmap."""
+ # Skip internal or invalid files
+ if not filename or filename.startswith('<') or filename.startswith('['):
+ return False
+
+ # Skip invalid frames with corrupted filename data
+ if filename == "__init__" and lineno == 0:
+ return False
+
+ return True
+
+ def _record_line_sample(self, filename, lineno, funcname, is_leaf=False):
+ """Record a sample for a specific line."""
+ # Track cumulative samples (all occurrences in stack)
+ self.line_samples[(filename, lineno)] += 1
+ self.file_samples[filename][lineno] += 1
+
+ # Track self/leaf samples (only when at top of stack)
+ if is_leaf:
+ self.line_self_samples[(filename, lineno)] += 1
+ self.file_self_samples[filename][lineno] += 1
+
+ # Record function definition location
+ if funcname and (filename, funcname) not in self.function_definitions:
+ self.function_definitions[(filename, funcname)] = lineno
+
+ def _record_call_relationship(self, callee_frame, caller_frame):
+ """Record caller/callee relationship between adjacent frames."""
+ callee_filename, callee_lineno, callee_funcname = callee_frame
+ caller_filename, caller_lineno, caller_funcname = caller_frame
+
+ # Skip internal files for call graph
+ if callee_filename.startswith('<') or callee_filename.startswith('['):
+ return
+
+ # Get the callee's function definition line
+ callee_def_line = self.function_definitions.get(
+ (callee_filename, callee_funcname), callee_lineno
+ )
+
+ # Record caller -> callee relationship
+ caller_key = (caller_filename, caller_lineno)
+ callee_info = (callee_filename, callee_def_line, callee_funcname)
+ if callee_info not in self.call_graph[caller_key]:
+ self.call_graph[caller_key].append(callee_info)
+
+ # Record callee <- caller relationship
+ callee_key = (callee_filename, callee_def_line)
+ caller_info = (caller_filename, caller_lineno, caller_funcname)
+ if caller_info not in self.callers_graph[callee_key]:
+ self.callers_graph[callee_key].append(caller_info)
+
+ # Count this call edge for path analysis
+ edge_key = (caller_key, callee_key)
+ self.edge_samples[edge_key] += 1
+
+ def export(self, output_path):
+ """Export heatmap data as HTML files in a directory.
+
+ Args:
+ output_path: Path where to create the heatmap output directory
+ """
+ if not self.file_samples:
+ print("Warning: No heatmap data to export")
+ return
+
+ try:
+ output_dir = self._prepare_output_directory(output_path)
+ file_stats = self._calculate_file_stats()
+ self._create_file_index(file_stats)
+
+ # Generate individual file reports
+ self._generate_file_reports(output_dir, file_stats)
+
+ # Generate index page
+ self._generate_index_html(output_dir / 'index.html', file_stats)
+
+ self._print_export_summary(output_dir, file_stats)
+
+ except Exception as e:
+ print(f"Error: Failed to export heatmap: {e}")
+ raise
+
+ def _prepare_output_directory(self, output_path):
+ """Create output directory for heatmap files."""
+ output_dir = Path(output_path)
+ if output_dir.suffix == '.html':
+ output_dir = output_dir.with_suffix('')
+
+ try:
+ output_dir.mkdir(exist_ok=True, parents=True)
+ except (IOError, OSError) as e:
+ raise RuntimeError(f"Failed to create output directory {output_dir}: {e}") from e
+
+ return output_dir
+
+ def _create_file_index(self, file_stats: List[FileStats]):
+ """Create mapping from filenames to HTML file names."""
+ self.file_index = {
+ stat.filename: self.FILE_INDEX_FORMAT.format(i)
+ for i, stat in enumerate(file_stats)
+ }
+
+ def _generate_file_reports(self, output_dir, file_stats: List[FileStats]):
+ """Generate HTML report for each source file."""
+ for stat in file_stats:
+ file_path = output_dir / self.file_index[stat.filename]
+ line_counts = self.file_samples[stat.filename]
+ valid_line_counts = {line: count for line, count in line_counts.items() if line >= 0}
+
+ self_counts = self.file_self_samples.get(stat.filename, {})
+ valid_self_counts = {line: count for line, count in self_counts.items() if line >= 0}
+
+ self._generate_file_html(
+ file_path,
+ stat.filename,
+ valid_line_counts,
+ valid_self_counts,
+ stat
+ )
+
+ def _print_export_summary(self, output_dir, file_stats: List[FileStats]):
+ """Print summary of exported heatmap."""
+ print(f"Heatmap output written to {output_dir}/")
+ print(f" - Index: {output_dir / 'index.html'}")
+ print(f" - {len(file_stats)} source file(s) analyzed")
+
+ def _calculate_file_stats(self) -> List[FileStats]:
+ """Calculate statistics for each file.
+
+ Returns:
+ List of FileStats objects sorted by total samples
+ """
+ file_stats = []
+ for filename, line_counts in self.file_samples.items():
+ # Skip special frames
+ if filename in ('~', '...', '.') or filename.startswith('<') or filename.startswith('['):
+ continue
+
+ # Filter out lines with -1 (special frames)
+ valid_line_counts = {line: count for line, count in line_counts.items() if line >= 0}
+ if not valid_line_counts:
+ continue
+
+ # Get self samples for this file
+ self_line_counts = self.file_self_samples.get(filename, {})
+ valid_self_counts = {line: count for line, count in self_line_counts.items() if line >= 0}
+
+ total_samples = sum(valid_line_counts.values())
+ total_self_samples = sum(valid_self_counts.values())
+ num_lines = len(valid_line_counts)
+ max_samples = max(valid_line_counts.values())
+ max_self_samples = max(valid_self_counts.values()) if valid_self_counts else 0
+ module_name, module_type = extract_module_name(filename, self._path_info)
+
+ file_stats.append(FileStats(
+ filename=filename,
+ module_name=module_name,
+ module_type=module_type,
+ total_samples=total_samples,
+ total_self_samples=total_self_samples,
+ num_lines=num_lines,
+ max_samples=max_samples,
+ max_self_samples=max_self_samples,
+ percentage=0.0
+ ))
+
+ # Sort by total samples and calculate percentages
+ file_stats.sort(key=lambda x: x.total_samples, reverse=True)
+ if file_stats:
+ max_total = file_stats[0].total_samples
+ for stat in file_stats:
+ stat.percentage = (stat.total_samples / max_total * 100) if max_total > 0 else 0
+
+ return file_stats
+
+ def _generate_index_html(self, index_path: Path, file_stats: List[FileStats]):
+ """Generate index.html with list of all profiled files."""
+ # Build hierarchical tree
+ tree = _TreeBuilder.build_file_tree(file_stats)
+
+ # Render tree as HTML
+ renderer = _HtmlRenderer(self.file_index, self._color_gradient,
+ self._calculate_intensity_color)
+ sections_html = renderer.render_hierarchical_html(tree)
+
+ # Format error rate and missed samples with bar classes
+ error_rate = self.stats.get('error_rate')
+ if error_rate is not None:
+ error_rate_str = f"{error_rate:.1f}%"
+ error_rate_width = min(error_rate, 100)
+ # Determine bar color class based on rate
+ if error_rate < 5:
+ error_rate_class = "good"
+ elif error_rate < 15:
+ error_rate_class = "warning"
+ else:
+ error_rate_class = "error"
+ else:
+ error_rate_str = "N/A"
+ error_rate_width = 0
+ error_rate_class = "good"
+
+ missed_samples = self.stats.get('missed_samples')
+ if missed_samples is not None:
+ missed_samples_str = f"{missed_samples:.1f}%"
+ missed_samples_width = min(missed_samples, 100)
+ if missed_samples < 5:
+ missed_samples_class = "good"
+ elif missed_samples < 15:
+ missed_samples_class = "warning"
+ else:
+ missed_samples_class = "error"
+ else:
+ missed_samples_str = "N/A"
+ missed_samples_width = 0
+ missed_samples_class = "good"
+
+ # Populate template
+ replacements = {
+ "<!-- INLINE_CSS -->": f"<style>\n{self._template_loader.index_css}\n</style>",
+ "<!-- INLINE_JS -->": f"<script>\n{self._template_loader.index_js}\n</script>",
+ "<!-- PYTHON_LOGO -->": self._template_loader.logo_html,
+ "<!-- NUM_FILES -->": str(len(file_stats)),
+ "<!-- TOTAL_SAMPLES -->": f"{self._total_samples:,}",
+ "<!-- DURATION -->": f"{self.stats.get('duration_sec', 0):.1f}s",
+ "<!-- SAMPLE_RATE -->": f"{self.stats.get('sample_rate', 0):.1f}",
+ "<!-- ERROR_RATE -->": error_rate_str,
+ "<!-- ERROR_RATE_WIDTH -->": str(error_rate_width),
+ "<!-- ERROR_RATE_CLASS -->": error_rate_class,
+ "<!-- MISSED_SAMPLES -->": missed_samples_str,
+ "<!-- MISSED_SAMPLES_WIDTH -->": str(missed_samples_width),
+ "<!-- MISSED_SAMPLES_CLASS -->": missed_samples_class,
+ "<!-- SECTIONS_HTML -->": sections_html,
+ }
+
+ html_content = self._template_loader.index_template
+ for placeholder, value in replacements.items():
+ html_content = html_content.replace(placeholder, value)
+
+ try:
+ index_path.write_text(html_content, encoding='utf-8')
+ except (IOError, OSError) as e:
+ raise RuntimeError(f"Failed to write index file {index_path}: {e}") from e
+
+ def _calculate_intensity_color(self, intensity: float) -> Tuple[int, int, int, float]:
+ """Calculate RGB color and alpha for given intensity (0-1 range).
+
+ Returns (r, g, b, alpha) tuple representing the heatmap color gradient:
+ blue -> green -> yellow -> orange -> red
+
+ Results are cached to improve performance.
+ """
+ # Round to 3 decimal places for cache key
+ cache_key = round(intensity, 3)
+ if cache_key in self._color_gradient.cache:
+ return self._color_gradient.cache[cache_key]
+
+ gradient = self._color_gradient
+ m = gradient.multiplier
+
+ # Color stops with (threshold, rgb_func, alpha_func)
+ stops = [
+ (gradient.stop_1,
+ lambda i: (0, int(150 * i * m), 255),
+ lambda i: gradient.alpha_very_cold),
+ (gradient.stop_2,
+ lambda i: (0, 255, int(255 * (1 - (i - gradient.stop_1) * m))),
+ lambda i: gradient.alpha_cold),
+ (gradient.stop_3,
+ lambda i: (int(255 * (i - gradient.stop_2) * m), 255, 0),
+ lambda i: gradient.alpha_medium),
+ (gradient.stop_4,
+ lambda i: (255, int(200 - 100 * (i - gradient.stop_3) * m), 0),
+ lambda i: gradient.alpha_warm),
+ (gradient.stop_5,
+ lambda i: (255, int(100 * (1 - (i - gradient.stop_4) * m)), 0),
+ lambda i: gradient.alpha_hot_base + gradient.alpha_hot_range * (i - gradient.stop_4) * m),
+ ]
+
+ result = None
+ for threshold, rgb_func, alpha_func in stops:
+ if intensity < threshold or threshold == gradient.stop_5:
+ r, g, b = rgb_func(intensity)
+ result = (r, g, b, alpha_func(intensity))
+ break
+
+ # Fallback
+ if result is None:
+ result = (255, 0, 0, 0.75)
+
+ # Cache the result
+ self._color_gradient.cache[cache_key] = result
+ return result
+
+ def _generate_file_html(self, output_path: Path, filename: str,
+ line_counts: Dict[int, int], self_counts: Dict[int, int],
+ file_stat: FileStats):
+ """Generate HTML for a single source file with heatmap coloring."""
+ # Read source file
+ try:
+ source_lines = Path(filename).read_text(encoding='utf-8', errors='replace').splitlines()
+ except (IOError, OSError) as e:
+ if not (filename.startswith('<') or filename.startswith('[') or
+ filename in ('~', '...', '.') or len(filename) < 2):
+ print(f"Warning: Could not read source file {filename}: {e}")
+ source_lines = [f"# Source file not available: {filename}"]
+
+ # Generate HTML for each line
+ max_samples = max(line_counts.values()) if line_counts else 1
+ max_self_samples = max(self_counts.values()) if self_counts else 1
+ code_lines_html = [
+ self._build_line_html(line_num, line_content, line_counts, self_counts,
+ max_samples, max_self_samples, filename)
+ for line_num, line_content in enumerate(source_lines, start=1)
+ ]
+
+ # Populate template
+ replacements = {
+ "<!-- FILENAME -->": html.escape(filename),
+ "<!-- TOTAL_SAMPLES -->": f"{file_stat.total_samples:,}",
+ "<!-- TOTAL_SELF_SAMPLES -->": f"{file_stat.total_self_samples:,}",
+ "<!-- NUM_LINES -->": str(file_stat.num_lines),
+ "<!-- PERCENTAGE -->": f"{file_stat.percentage:.2f}",
+ "<!-- MAX_SAMPLES -->": str(file_stat.max_samples),
+ "<!-- MAX_SELF_SAMPLES -->": str(file_stat.max_self_samples),
+ "<!-- CODE_LINES -->": ''.join(code_lines_html),
+ "<!-- INLINE_CSS -->": f"<style>\n{self._template_loader.file_css}\n</style>",
+ "<!-- INLINE_JS -->": f"<script>\n{self._template_loader.file_js}\n</script>",
+ }
+
+ html_content = self._template_loader.file_template
+ for placeholder, value in replacements.items():
+ html_content = html_content.replace(placeholder, value)
+
+ try:
+ output_path.write_text(html_content, encoding='utf-8')
+ except (IOError, OSError) as e:
+ raise RuntimeError(f"Failed to write file {output_path}: {e}") from e
+
+ def _build_line_html(self, line_num: int, line_content: str,
+ line_counts: Dict[int, int], self_counts: Dict[int, int],
+ max_samples: int, max_self_samples: int, filename: str) -> str:
+ """Build HTML for a single line of source code."""
+ cumulative_samples = line_counts.get(line_num, 0)
+ self_samples = self_counts.get(line_num, 0)
+
+ # Calculate colors for both self and cumulative modes
+ if cumulative_samples > 0:
+ cumulative_intensity = cumulative_samples / max_samples if max_samples > 0 else 0
+ self_intensity = self_samples / max_self_samples if max_self_samples > 0 and self_samples > 0 else 0
+
+ # Default to self-based coloring
+ intensity = self_intensity if self_samples > 0 else cumulative_intensity
+ r, g, b, alpha = self._calculate_intensity_color(intensity)
+ bg_color = f"rgba({r}, {g}, {b}, {alpha})"
+
+ # Pre-calculate colors for both modes (for JS toggle)
+ self_bg_color = self._format_color_for_intensity(self_intensity) if self_samples > 0 else "transparent"
+ cumulative_bg_color = self._format_color_for_intensity(cumulative_intensity)
+
+ self_display = f"{self_samples:,}" if self_samples > 0 else ""
+ cumulative_display = f"{cumulative_samples:,}"
+ tooltip = f"Self: {self_samples:,}, Total: {cumulative_samples:,}"
+ else:
+ bg_color = "transparent"
+ self_bg_color = "transparent"
+ cumulative_bg_color = "transparent"
+ self_display = ""
+ cumulative_display = ""
+ tooltip = ""
+
+ # Get navigation buttons
+ nav_buttons_html = self._build_navigation_buttons(filename, line_num)
+
+ # Build line HTML
+ line_html = html.escape(line_content.rstrip('\n'))
+ title_attr = f' title="{html.escape(tooltip)}"' if tooltip else ""
+
+ return (
+ f' <div class="code-line" data-bg-color="{bg_color}" '
+ f'data-self-color="{self_bg_color}" data-cumulative-color="{cumulative_bg_color}" '
+ f'id="line-{line_num}"{title_attr}>\n'
+ f' <div class="line-number">{line_num}</div>\n'
+ f' <div class="line-samples-self">{self_display}</div>\n'
+ f' <div class="line-samples-cumulative">{cumulative_display}</div>\n'
+ f' <div class="line-content">{line_html}</div>\n'
+ f' {nav_buttons_html}\n'
+ f' </div>\n'
+ )
+
+ def _format_color_for_intensity(self, intensity: float) -> str:
+ """Format color as rgba() string for given intensity."""
+ r, g, b, alpha = self._calculate_intensity_color(intensity)
+ return f"rgba({r}, {g}, {b}, {alpha})"
+
+ def _build_navigation_buttons(self, filename: str, line_num: int) -> str:
+ """Build navigation buttons for callers/callees."""
+ line_key = (filename, line_num)
+ caller_list = self._deduplicate_by_function(self.callers_graph.get(line_key, []))
+ callee_list = self._deduplicate_by_function(self.call_graph.get(line_key, []))
+
+ # Get edge counts for each caller/callee
+ callers_with_counts = self._get_edge_counts(line_key, caller_list, is_caller=True)
+ callees_with_counts = self._get_edge_counts(line_key, callee_list, is_caller=False)
+
+ # Build navigation buttons with counts
+ caller_btn = self._create_navigation_button(callers_with_counts, 'caller', '▲')
+ callee_btn = self._create_navigation_button(callees_with_counts, 'callee', '▼')
+
+ if caller_btn or callee_btn:
+ return f'<div class="line-nav-buttons">{caller_btn}{callee_btn}</div>'
+ return ''
+
+ def _get_edge_counts(self, line_key: Tuple[str, int],
+ items: List[Tuple[str, int, str]],
+ is_caller: bool) -> List[Tuple[str, int, str, int]]:
+ """Get sample counts for each caller/callee edge."""
+ result = []
+ for file, line, func in items:
+ edge_line_key = (file, line)
+ if is_caller:
+ edge_key = (edge_line_key, line_key)
+ else:
+ edge_key = (line_key, edge_line_key)
+
+ count = self.edge_samples.get(edge_key, 0)
+ result.append((file, line, func, count))
+
+ result.sort(key=lambda x: x[3], reverse=True)
+ return result
+
+ def _deduplicate_by_function(self, items: List[Tuple[str, int, str]]) -> List[Tuple[str, int, str]]:
+ """Remove duplicate entries based on (file, function) key."""
+ seen = {}
+ result = []
+ for file, line, func in items:
+ key = (file, func)
+ if key not in seen:
+ seen[key] = True
+ result.append((file, line, func))
+ return result
+
+ def _create_navigation_button(self, items_with_counts: List[Tuple[str, int, str, int]],
+ btn_class: str, arrow: str) -> str:
+ """Create HTML for a navigation button with sample counts."""
+ # Filter valid items
+ valid_items = [(f, l, fn, cnt) for f, l, fn, cnt in items_with_counts
+ if f in self.file_index and l > 0]
+ if not valid_items:
+ return ""
+
+ if len(valid_items) == 1:
+ file, line, func, count = valid_items[0]
+ target_html = self.file_index[file]
+ nav_data = json.dumps({'link': f"{target_html}#line-{line}", 'func': func})
+ title = f"Go to {btn_class}: {html.escape(func)} ({count:,} samples)"
+ return f'<button class="nav-btn {btn_class}" data-nav=\'{html.escape(nav_data)}\' title="{title}">{arrow}</button>'
+
+ # Multiple items - create menu
+ total_samples = sum(cnt for _, _, _, cnt in valid_items)
+ items_data = [
+ {
+ 'file': os.path.basename(file),
+ 'func': func,
+ 'count': count,
+ 'link': f"{self.file_index[file]}#line-{line}"
+ }
+ for file, line, func, count in valid_items
+ ]
+ items_json = html.escape(json.dumps(items_data))
+ title = f"{len(items_data)} {btn_class}s ({total_samples:,} samples)"
+ return f'<button class="nav-btn {btn_class}" data-nav-multi=\'{items_json}\' title="{title}">{arrow}</button>'
from .pstats_collector import PstatsCollector
from .stack_collector import CollapsedStackCollector, FlamegraphCollector
+from .heatmap_collector import HeatmapCollector
from .gecko_collector import GeckoCollector
from .constants import (
PROFILING_MODE_WALL,
_FREE_THREADED_BUILD = sysconfig.get_config_var("Py_GIL_DISABLED") is not None
-
class SampleProfiler:
def __init__(self, pid, sample_interval_usec, all_threads, *, mode=PROFILING_MODE_WALL, native=False, gc=True, skip_non_matching_threads=True):
self.pid = pid
import linecache
import os
+from ._css_utils import get_combined_css
from .collector import Collector
from .string_table import StringTable
fg_js_path = d3_flame_graph_dir / "d3-flamegraph.min.js"
fg_tooltip_js_path = d3_flame_graph_dir / "d3-flamegraph-tooltip.min.js"
- html_template = (template_dir / "flamegraph_template.html").read_text(encoding="utf-8")
- css_content = (template_dir / "flamegraph.css").read_text(encoding="utf-8")
- js_content = (template_dir / "flamegraph.js").read_text(encoding="utf-8")
+ html_template = (template_dir / "_flamegraph_assets" / "flamegraph_template.html").read_text(encoding="utf-8")
+ css_content = get_combined_css("flamegraph")
+ js_content = (template_dir / "_flamegraph_assets" / "flamegraph.js").read_text(encoding="utf-8")
# Inline first-party CSS/JS
html_template = html_template.replace(
--- /dev/null
+"""Tests for the heatmap collector (profiling.sampling)."""
+
+import os
+import shutil
+import tempfile
+import unittest
+from pathlib import Path
+
+from profiling.sampling.heatmap_collector import (
+ HeatmapCollector,
+ get_python_path_info,
+ extract_module_name,
+)
+
+from test.support import captured_stdout, captured_stderr
+
+
+# =============================================================================
+# Unit Tests for Public Helper Functions
+# =============================================================================
+
+class TestPathInfoFunctions(unittest.TestCase):
+ """Test public helper functions for path information."""
+
+ def test_get_python_path_info_returns_dict(self):
+ """Test that get_python_path_info returns a dictionary with expected keys."""
+ path_info = get_python_path_info()
+
+ self.assertIsInstance(path_info, dict)
+ self.assertIn('stdlib', path_info)
+ self.assertIn('site_packages', path_info)
+ self.assertIn('sys_path', path_info)
+
+ def test_get_python_path_info_stdlib_is_path_or_none(self):
+ """Test that stdlib is either a Path object or None."""
+ path_info = get_python_path_info()
+
+ if path_info['stdlib'] is not None:
+ self.assertIsInstance(path_info['stdlib'], Path)
+
+ def test_get_python_path_info_site_packages_is_list(self):
+ """Test that site_packages is a list."""
+ path_info = get_python_path_info()
+
+ self.assertIsInstance(path_info['site_packages'], list)
+ for item in path_info['site_packages']:
+ self.assertIsInstance(item, Path)
+
+ def test_get_python_path_info_sys_path_is_list(self):
+ """Test that sys_path is a list of Path objects."""
+ path_info = get_python_path_info()
+
+ self.assertIsInstance(path_info['sys_path'], list)
+ for item in path_info['sys_path']:
+ self.assertIsInstance(item, Path)
+
+ def test_extract_module_name_with_none(self):
+ """Test extract_module_name with None filename."""
+ path_info = get_python_path_info()
+ module_name, module_type = extract_module_name(None, path_info)
+
+ self.assertEqual(module_name, 'unknown')
+ self.assertEqual(module_type, 'other')
+
+ def test_extract_module_name_with_empty_string(self):
+ """Test extract_module_name with empty filename."""
+ path_info = get_python_path_info()
+ module_name, module_type = extract_module_name('', path_info)
+
+ self.assertEqual(module_name, 'unknown')
+ self.assertEqual(module_type, 'other')
+
+ def test_extract_module_name_with_stdlib_file(self):
+ """Test extract_module_name with a standard library file."""
+ path_info = get_python_path_info()
+
+ # Use os module as a known stdlib file
+ if path_info['stdlib']:
+ stdlib_file = str(path_info['stdlib'] / 'os.py')
+ module_name, module_type = extract_module_name(stdlib_file, path_info)
+
+ self.assertEqual(module_type, 'stdlib')
+ self.assertIn('os', module_name)
+
+ def test_extract_module_name_with_project_file(self):
+ """Test extract_module_name with a project file."""
+ path_info = get_python_path_info()
+
+ # Create a mock project file path
+ if path_info['sys_path']:
+ # Use current directory as project path
+ project_file = '/some/project/path/mymodule.py'
+ module_name, module_type = extract_module_name(project_file, path_info)
+
+ # Should classify as 'other' if not in sys.path
+ self.assertIn(module_type, ['project', 'other'])
+
+ def test_extract_module_name_removes_py_extension(self):
+ """Test that .py extension is removed from module names."""
+ path_info = get_python_path_info()
+
+ # Test with a simple .py file
+ module_name, module_type = extract_module_name('/path/to/test.py', path_info)
+
+ # Module name should not contain .py
+ self.assertNotIn('.py', module_name)
+
+ def test_extract_module_name_with_special_files(self):
+ """Test extract_module_name with special filenames like <string>."""
+ path_info = get_python_path_info()
+
+ special_files = ['<string>', '<stdin>', '[eval]']
+ for special_file in special_files:
+ module_name, module_type = extract_module_name(special_file, path_info)
+ self.assertEqual(module_type, 'other')
+
+
+# =============================================================================
+# Unit Tests for HeatmapCollector Public API
+# =============================================================================
+
+class TestHeatmapCollectorInit(unittest.TestCase):
+ """Test HeatmapCollector initialization."""
+
+ def test_init_creates_empty_data_structures(self):
+ """Test that __init__ creates empty data structures."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ # Check that data structures are initialized
+ self.assertIsInstance(collector.line_samples, dict)
+ self.assertIsInstance(collector.file_samples, dict)
+ self.assertIsInstance(collector.line_self_samples, dict)
+ self.assertIsInstance(collector.file_self_samples, dict)
+ self.assertIsInstance(collector.call_graph, dict)
+ self.assertIsInstance(collector.callers_graph, dict)
+ self.assertIsInstance(collector.function_definitions, dict)
+ self.assertIsInstance(collector.edge_samples, dict)
+
+ # Check that they're empty
+ self.assertEqual(len(collector.line_samples), 0)
+ self.assertEqual(len(collector.file_samples), 0)
+ self.assertEqual(len(collector.line_self_samples), 0)
+ self.assertEqual(len(collector.file_self_samples), 0)
+
+ def test_init_sets_total_samples_to_zero(self):
+ """Test that total samples starts at zero."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+ self.assertEqual(collector._total_samples, 0)
+
+ def test_init_creates_color_cache(self):
+ """Test that color cache is initialized."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+ self.assertIsInstance(collector._color_cache, dict)
+ self.assertEqual(len(collector._color_cache), 0)
+
+ def test_init_gets_path_info(self):
+ """Test that path info is retrieved during init."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+ self.assertIsNotNone(collector._path_info)
+ self.assertIn('stdlib', collector._path_info)
+
+
+class TestHeatmapCollectorSetStats(unittest.TestCase):
+ """Test HeatmapCollector.set_stats() method."""
+
+ def test_set_stats_stores_all_parameters(self):
+ """Test that set_stats stores all provided parameters."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ collector.set_stats(
+ sample_interval_usec=500,
+ duration_sec=10.5,
+ sample_rate=99.5,
+ error_rate=0.5
+ )
+
+ self.assertEqual(collector.stats['sample_interval_usec'], 500)
+ self.assertEqual(collector.stats['duration_sec'], 10.5)
+ self.assertEqual(collector.stats['sample_rate'], 99.5)
+ self.assertEqual(collector.stats['error_rate'], 0.5)
+
+ def test_set_stats_includes_system_info(self):
+ """Test that set_stats includes Python and platform info."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+ collector.set_stats(sample_interval_usec=100, duration_sec=1.0, sample_rate=100.0)
+
+ self.assertIn('python_version', collector.stats)
+ self.assertIn('python_implementation', collector.stats)
+ self.assertIn('platform', collector.stats)
+
+ def test_set_stats_accepts_kwargs(self):
+ """Test that set_stats accepts additional kwargs."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ collector.set_stats(
+ sample_interval_usec=100,
+ duration_sec=1.0,
+ sample_rate=100.0,
+ custom_key='custom_value',
+ another_key=42
+ )
+
+ self.assertEqual(collector.stats['custom_key'], 'custom_value')
+ self.assertEqual(collector.stats['another_key'], 42)
+
+ def test_set_stats_with_none_error_rate(self):
+ """Test set_stats with error_rate=None."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+ collector.set_stats(sample_interval_usec=100, duration_sec=1.0, sample_rate=100.0)
+
+ self.assertIn('error_rate', collector.stats)
+ self.assertIsNone(collector.stats['error_rate'])
+
+
+class TestHeatmapCollectorProcessFrames(unittest.TestCase):
+ """Test HeatmapCollector.process_frames() method."""
+
+ def test_process_frames_increments_total_samples(self):
+ """Test that process_frames increments total samples count."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ initial_count = collector._total_samples
+ frames = [('file.py', 10, 'func')]
+ collector.process_frames(frames, thread_id=1)
+
+ self.assertEqual(collector._total_samples, initial_count + 1)
+
+ def test_process_frames_records_line_samples(self):
+ """Test that process_frames records line samples."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ frames = [('test.py', 5, 'test_func')]
+ collector.process_frames(frames, thread_id=1)
+
+ # Check that line was recorded
+ self.assertIn(('test.py', 5), collector.line_samples)
+ self.assertEqual(collector.line_samples[('test.py', 5)], 1)
+
+ def test_process_frames_records_multiple_lines_in_stack(self):
+ """Test that process_frames records all lines in a stack."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ frames = [
+ ('file1.py', 10, 'func1'),
+ ('file2.py', 20, 'func2'),
+ ('file3.py', 30, 'func3')
+ ]
+ collector.process_frames(frames, thread_id=1)
+
+ # All frames should be recorded
+ self.assertIn(('file1.py', 10), collector.line_samples)
+ self.assertIn(('file2.py', 20), collector.line_samples)
+ self.assertIn(('file3.py', 30), collector.line_samples)
+
+ def test_process_frames_distinguishes_self_samples(self):
+ """Test that process_frames distinguishes self (leaf) samples."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ frames = [
+ ('leaf.py', 5, 'leaf_func'), # This is the leaf (top of stack)
+ ('caller.py', 10, 'caller_func')
+ ]
+ collector.process_frames(frames, thread_id=1)
+
+ # Leaf should have self sample
+ self.assertIn(('leaf.py', 5), collector.line_self_samples)
+ self.assertEqual(collector.line_self_samples[('leaf.py', 5)], 1)
+
+ # Caller should NOT have self sample
+ self.assertNotIn(('caller.py', 10), collector.line_self_samples)
+
+ def test_process_frames_accumulates_samples(self):
+ """Test that multiple calls accumulate samples."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ frames = [('file.py', 10, 'func')]
+
+ collector.process_frames(frames, thread_id=1)
+ collector.process_frames(frames, thread_id=1)
+ collector.process_frames(frames, thread_id=1)
+
+ self.assertEqual(collector.line_samples[('file.py', 10)], 3)
+ self.assertEqual(collector._total_samples, 3)
+
+ def test_process_frames_ignores_invalid_frames(self):
+ """Test that process_frames ignores invalid frames."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ # These should be ignored
+ invalid_frames = [
+ ('<string>', 1, 'test'),
+ ('[eval]', 1, 'test'),
+ ('', 1, 'test'),
+ (None, 1, 'test'),
+ ('__init__', 0, 'test'), # Special invalid frame
+ ]
+
+ for frame in invalid_frames:
+ collector.process_frames([frame], thread_id=1)
+
+ # Should not record these invalid frames
+ for frame in invalid_frames:
+ if frame[0]:
+ self.assertNotIn((frame[0], frame[1]), collector.line_samples)
+
+ def test_process_frames_builds_call_graph(self):
+ """Test that process_frames builds call graph relationships."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ frames = [
+ ('callee.py', 5, 'callee_func'),
+ ('caller.py', 10, 'caller_func')
+ ]
+ collector.process_frames(frames, thread_id=1)
+
+ # Check that call relationship was recorded
+ caller_key = ('caller.py', 10)
+ self.assertIn(caller_key, collector.call_graph)
+
+ # Check callers graph
+ callee_key = ('callee.py', 5)
+ self.assertIn(callee_key, collector.callers_graph)
+
+ def test_process_frames_records_function_definitions(self):
+ """Test that process_frames records function definition locations."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ frames = [('module.py', 42, 'my_function')]
+ collector.process_frames(frames, thread_id=1)
+
+ self.assertIn(('module.py', 'my_function'), collector.function_definitions)
+ self.assertEqual(collector.function_definitions[('module.py', 'my_function')], 42)
+
+ def test_process_frames_tracks_edge_samples(self):
+ """Test that process_frames tracks edge sample counts."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ frames = [
+ ('callee.py', 5, 'callee'),
+ ('caller.py', 10, 'caller')
+ ]
+
+ # Process same call stack multiple times
+ collector.process_frames(frames, thread_id=1)
+ collector.process_frames(frames, thread_id=1)
+
+ # Check that edge count is tracked
+ self.assertGreater(len(collector.edge_samples), 0)
+
+ def test_process_frames_handles_empty_frames(self):
+ """Test that process_frames handles empty frame list."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ initial_count = collector._total_samples
+ collector.process_frames([], thread_id=1)
+
+ # Should still increment total samples
+ self.assertEqual(collector._total_samples, initial_count + 1)
+
+ def test_process_frames_with_file_samples_dict(self):
+ """Test that file_samples dict is properly populated."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ frames = [('test.py', 10, 'func')]
+ collector.process_frames(frames, thread_id=1)
+
+ self.assertIn('test.py', collector.file_samples)
+ self.assertIn(10, collector.file_samples['test.py'])
+ self.assertEqual(collector.file_samples['test.py'][10], 1)
+
+
+class TestHeatmapCollectorExport(unittest.TestCase):
+ """Test HeatmapCollector.export() method."""
+
+ def setUp(self):
+ """Set up test directory."""
+ self.test_dir = tempfile.mkdtemp()
+ self.addCleanup(shutil.rmtree, self.test_dir)
+
+ def test_export_creates_output_directory(self):
+ """Test that export creates the output directory."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ # Add some data
+ frames = [('test.py', 10, 'func')]
+ collector.process_frames(frames, thread_id=1)
+
+ output_path = os.path.join(self.test_dir, 'heatmap_output')
+
+ with captured_stdout(), captured_stderr():
+ collector.export(output_path)
+
+ self.assertTrue(os.path.exists(output_path))
+ self.assertTrue(os.path.isdir(output_path))
+
+ def test_export_creates_index_html(self):
+ """Test that export creates index.html."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ frames = [('test.py', 10, 'func')]
+ collector.process_frames(frames, thread_id=1)
+
+ output_path = os.path.join(self.test_dir, 'heatmap_output')
+
+ with captured_stdout(), captured_stderr():
+ collector.export(output_path)
+
+ index_path = os.path.join(output_path, 'index.html')
+ self.assertTrue(os.path.exists(index_path))
+
+ def test_export_creates_file_htmls(self):
+ """Test that export creates individual file HTMLs."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ frames = [('test.py', 10, 'func')]
+ collector.process_frames(frames, thread_id=1)
+
+ output_path = os.path.join(self.test_dir, 'heatmap_output')
+
+ with captured_stdout(), captured_stderr():
+ collector.export(output_path)
+
+ # Check for file_XXXX.html files
+ html_files = [f for f in os.listdir(output_path)
+ if f.startswith('file_') and f.endswith('.html')]
+ self.assertGreater(len(html_files), 0)
+
+ def test_export_with_empty_data(self):
+ """Test export with no data collected."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ output_path = os.path.join(self.test_dir, 'empty_output')
+
+ # Should handle empty data gracefully
+ with captured_stdout(), captured_stderr():
+ collector.export(output_path)
+
+ def test_export_handles_html_suffix(self):
+ """Test that export handles .html suffix in output path."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ frames = [('test.py', 10, 'func')]
+ collector.process_frames(frames, thread_id=1)
+
+ # Path with .html suffix should be stripped
+ output_path = os.path.join(self.test_dir, 'output.html')
+
+ with captured_stdout(), captured_stderr():
+ collector.export(output_path)
+
+ # Should create directory without .html
+ expected_dir = os.path.join(self.test_dir, 'output')
+ self.assertTrue(os.path.exists(expected_dir))
+
+ def test_export_with_multiple_files(self):
+ """Test export with multiple files."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ # Add samples for multiple files
+ collector.process_frames([('file1.py', 10, 'func1')], thread_id=1)
+ collector.process_frames([('file2.py', 20, 'func2')], thread_id=1)
+ collector.process_frames([('file3.py', 30, 'func3')], thread_id=1)
+
+ output_path = os.path.join(self.test_dir, 'multi_file')
+
+ with captured_stdout(), captured_stderr():
+ collector.export(output_path)
+
+ # Should create HTML for each file
+ html_files = [f for f in os.listdir(output_path)
+ if f.startswith('file_') and f.endswith('.html')]
+ self.assertGreaterEqual(len(html_files), 3)
+
+ def test_export_index_contains_file_references(self):
+ """Test that index.html contains references to profiled files."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+ collector.set_stats(sample_interval_usec=100, duration_sec=1.0, sample_rate=100.0)
+
+ frames = [('mytest.py', 10, 'my_func')]
+ collector.process_frames(frames, thread_id=1)
+
+ output_path = os.path.join(self.test_dir, 'test_output')
+
+ with captured_stdout(), captured_stderr():
+ collector.export(output_path)
+
+ index_path = os.path.join(output_path, 'index.html')
+ with open(index_path, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ # Should contain reference to the file
+ self.assertIn('mytest', content)
+
+ def test_export_file_html_has_line_numbers(self):
+ """Test that exported file HTML contains line numbers."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ # Create a temporary Python file
+ temp_file = os.path.join(self.test_dir, 'temp_source.py')
+ with open(temp_file, 'w') as f:
+ f.write('def test():\n pass\n')
+
+ frames = [(temp_file, 1, 'test')]
+ collector.process_frames(frames, thread_id=1)
+
+ output_path = os.path.join(self.test_dir, 'line_test')
+
+ with captured_stdout(), captured_stderr():
+ collector.export(output_path)
+
+ # Find the generated file HTML
+ html_files = [f for f in os.listdir(output_path)
+ if f.startswith('file_') and f.endswith('.html')]
+
+ if html_files:
+ with open(os.path.join(output_path, html_files[0]), 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ # Should have line-related content
+ self.assertIn('line-', content)
+
+
+class MockFrameInfo:
+ """Mock FrameInfo for testing since the real one isn't accessible."""
+
+ def __init__(self, filename, lineno, funcname):
+ self.filename = filename
+ self.lineno = lineno
+ self.funcname = funcname
+
+ def __repr__(self):
+ return f"MockFrameInfo(filename='{self.filename}', lineno={self.lineno}, funcname='{self.funcname}')"
+
+
+class MockThreadInfo:
+ """Mock ThreadInfo for testing since the real one isn't accessible."""
+
+ def __init__(self, thread_id, frame_info):
+ self.thread_id = thread_id
+ self.frame_info = frame_info
+
+ def __repr__(self):
+ return f"MockThreadInfo(thread_id={self.thread_id}, frame_info={self.frame_info})"
+
+
+class MockInterpreterInfo:
+ """Mock InterpreterInfo for testing since the real one isn't accessible."""
+
+ def __init__(self, interpreter_id, threads):
+ self.interpreter_id = interpreter_id
+ self.threads = threads
+
+ def __repr__(self):
+ return f"MockInterpreterInfo(interpreter_id={self.interpreter_id}, threads={self.threads})"
+
+
+class TestHeatmapCollector(unittest.TestCase):
+ """Tests for HeatmapCollector functionality."""
+
+ def test_heatmap_collector_basic(self):
+ """Test basic HeatmapCollector functionality."""
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ # Test empty state
+ self.assertEqual(len(collector.file_samples), 0)
+ self.assertEqual(len(collector.line_samples), 0)
+
+ # Test collecting sample data
+ test_frames = [
+ MockInterpreterInfo(
+ 0,
+ [MockThreadInfo(
+ 1,
+ [("file.py", 10, "func1"), ("file.py", 20, "func2")],
+ )]
+ )
+ ]
+ collector.collect(test_frames)
+
+ # Should have recorded samples for the file
+ self.assertGreater(len(collector.line_samples), 0)
+ self.assertIn("file.py", collector.file_samples)
+
+ # Check that line samples were recorded
+ file_data = collector.file_samples["file.py"]
+ self.assertGreater(len(file_data), 0)
+
+ def test_heatmap_collector_export(self):
+ """Test heatmap HTML export functionality."""
+ heatmap_dir = tempfile.mkdtemp()
+ self.addCleanup(shutil.rmtree, heatmap_dir)
+
+ collector = HeatmapCollector(sample_interval_usec=100)
+
+ # Create test data with multiple files
+ test_frames1 = [
+ MockInterpreterInfo(
+ 0,
+ [MockThreadInfo(1, [("file.py", 10, "func1"), ("file.py", 20, "func2")])],
+ )
+ ]
+ test_frames2 = [
+ MockInterpreterInfo(
+ 0,
+ [MockThreadInfo(1, [("file.py", 10, "func1"), ("file.py", 20, "func2")])],
+ )
+ ] # Same stack
+ test_frames3 = [
+ MockInterpreterInfo(0, [MockThreadInfo(1, [("other.py", 5, "other_func")])])
+ ]
+
+ collector.collect(test_frames1)
+ collector.collect(test_frames2)
+ collector.collect(test_frames3)
+
+ # Export heatmap
+ with (captured_stdout(), captured_stderr()):
+ collector.export(heatmap_dir)
+
+ # Verify index.html was created
+ index_path = os.path.join(heatmap_dir, "index.html")
+ self.assertTrue(os.path.exists(index_path))
+ self.assertGreater(os.path.getsize(index_path), 0)
+
+ # Check index contains HTML content
+ with open(index_path, "r", encoding="utf-8") as f:
+ content = f.read()
+
+ # Should be valid HTML
+ self.assertIn("<!doctype html>", content.lower())
+ self.assertIn("<html", content)
+ self.assertIn("Tachyon Profiler", content)
+
+ # Should contain file references
+ self.assertIn("file.py", content)
+ self.assertIn("other.py", content)
+
+ # Verify individual file HTMLs were created
+ file_htmls = [f for f in os.listdir(heatmap_dir) if f.startswith("file_") and f.endswith(".html")]
+ self.assertGreater(len(file_htmls), 0)
+
+ # Check one of the file HTMLs
+ file_html_path = os.path.join(heatmap_dir, file_htmls[0])
+ with open(file_html_path, "r", encoding="utf-8") as f:
+ file_content = f.read()
+
+ # Should contain heatmap styling and JavaScript
+ self.assertIn("line-sample", file_content)
+ self.assertIn("nav-btn", file_content)
+
+
+if __name__ == "__main__":
+ unittest.main()
profile \
profiling profiling/sampling profiling/tracing \
profiling/sampling/_assets \
+ profiling/sampling/_heatmap_assets \
+ profiling/sampling/_flamegraph_assets \
+ profiling/sampling/_shared_assets \
profiling/sampling/live_collector \
profiling/sampling/_vendor/d3/7.8.5 \
profiling/sampling/_vendor/d3-flame-graph/4.1.3 \
--- /dev/null
+Add heatmap visualization mode to the Tachyon sampling profiler. The new
+``--heatmap`` output format provides a line-by-line view showing execution
+intensity with color-coded samples, inline statistics, and interactive call
+graph navigation between callers and callees.