diff --git a/Lib/profiling/sampling/flamegraph.css b/Lib/profiling/sampling/flamegraph.css index 67754ca609aa43..6baa0350730db6 100644 --- a/Lib/profiling/sampling/flamegraph.css +++ b/Lib/profiling/sampling/flamegraph.css @@ -1,31 +1,353 @@ +/* ======================================== + CSS CUSTOM PROPERTIES & THEMING + ======================================== */ + +:root { + /* Color Palette - Light Mode */ + --color-primary: #3776ab; + --color-primary-dark: #2d5aa0; + --color-primary-light: #4584bb; + --color-accent: #ffd43b; + --color-accent-dark: #ffcd02; + + /* Semantic Colors */ + --color-bg-main: #ffffff; + --color-bg-secondary: #f8f9fa; + --color-bg-tertiary: #e9ecef; + --color-surface: #ffffff; + --color-border: #e9ecef; + --color-border-hover: #d0d5dd; + + /* Text Colors */ + --color-text-primary: #2e3338; + --color-text-secondary: #5a6c7d; + --color-text-muted: #8c9ba8; + --color-text-inverse: #ffffff; + + /* Status Colors */ + --color-success: #28a745; + --color-warning: #dc3545; + --color-info: #17a2b8; + + /* Shadows */ + --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12); + --shadow-xl: 0 12px 36px rgba(0, 0, 0, 0.16); + + /* Spacing */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + --spacing-2xl: 48px; + + /* Border Radius */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-full: 9999px; + + /* Typography */ + --font-family-base: "Source Sans Pro", "Segoe UI", -apple-system, BlinkMacSystemFont, "Roboto", sans-serif; + --font-family-mono: "SF Mono", "Monaco", "Consolas", "Courier New", monospace; + + --font-size-xs: 12px; + --font-size-sm: 13px; + --font-size-base: 14px; + --font-size-md: 15px; + --font-size-lg: 16px; + --font-size-xl: 18px; + --font-size-2xl: 22px; + --font-size-3xl: 28px; + + /* Transitions */ + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1); + + /* Animations */ + --tooltip-delay: 0.2s; + + /* Z-Index Scale */ + --z-base: 1; + --z-dropdown: 1000; + --z-sticky: 1020; + --z-fixed: 1030; + --z-modal-backdrop: 1040; + --z-modal: 1050; + --z-tooltip: 1070; +} + +/* ======================================== + BASE STYLES + ======================================== */ + +* { + box-sizing: border-box; +} + body { - font-family: - "Source Sans Pro", "Lucida Grande", "Lucida Sans Unicode", "Geneva", - "Verdana", sans-serif; + font-family: var(--font-family-base); margin: 0; padding: 0; - background: #ffffff; - color: #2e3338; + background: var(--color-bg-main); + color: var(--color-text-primary); line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ======================================== + SHARED COMPONENTS + ======================================== */ + +/* Shared Card Styles */ +.card { + background: var(--color-surface); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + padding: var(--spacing-md); + box-shadow: var(--shadow-sm); + transition: all var(--transition-base); +} + +.card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +/* Section Headings */ +.section-heading { + margin: var(--spacing-xl) 0 var(--spacing-md) 0; + color: var(--color-text-primary); + font-size: var(--font-size-2xl); + font-weight: 600; + letter-spacing: -0.3px; +} + +/* Screen Reader Only */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +/* ======================================== + FOCUS STATES & ACCESSIBILITY + ======================================== */ + +*:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +/* ======================================== + MODAL STYLES + ======================================== */ + +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + z-index: var(--z-modal); + align-items: center; + justify-content: center; + animation: fadeIn var(--transition-base); +} + +.modal.active { + display: flex; +} + +.modal-content { + background: var(--color-surface); + border-radius: var(--radius-xl); + max-width: 600px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + box-shadow: var(--shadow-xl); + animation: slideInScale var(--transition-slow); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-lg); + border-bottom: 1px solid var(--color-border); +} + +.modal-header h2 { + margin: 0; + font-size: var(--font-size-2xl); + color: var(--color-text-primary); + font-weight: 600; +} + +.modal-close { + background: transparent; + border: none; + font-size: 32px; + color: var(--color-text-secondary); + cursor: pointer; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + transition: all var(--transition-fast); + line-height: 1; + padding: 0; +} + +.modal-close:hover { + background: var(--color-bg-secondary); + color: var(--color-text-primary); +} + +.modal-body { + padding: var(--spacing-lg); +} + +.shortcut-section { + margin-bottom: var(--spacing-xl); +} + +.shortcut-section:last-child { + margin-bottom: 0; +} + +.shortcut-section h3 { + margin: 0 0 var(--spacing-md) 0; + font-size: var(--font-size-lg); + color: var(--color-text-primary); + font-weight: 600; +} + +.shortcut-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-sm) 0; + color: var(--color-text-secondary); + gap: var(--spacing-md); +} + +.shortcut-row kbd { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--spacing-xs) var(--spacing-sm); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--color-text-primary); + box-shadow: 0 1px 0 var(--color-border), + 0 2px 2px rgba(0, 0, 0, 0.05); + min-width: 32px; + text-align: center; + display: inline-block; +} + +/* ======================================== + LOADING STATES + ======================================== */ + +.loading-skeleton { + background: linear-gradient( + 90deg, + var(--color-bg-secondary) 0%, + var(--color-bg-tertiary) 50%, + var(--color-bg-secondary) 100% + ); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: var(--radius-sm); + display: inline-block; + height: 1em; + width: 100px; +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideInScale { + from { + opacity: 0; + transform: translateY(-20px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } } +/* ======================================== + HEADER STYLES + ======================================== */ + .header { - background: linear-gradient(135deg, #3776ab 0%, #4584bb 100%); - color: white; - padding: 32px 0; - box-shadow: 0 2px 10px rgba(55, 118, 171, 0.2); + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%); + color: var(--color-text-inverse); + padding: var(--spacing-xl) 0; + box-shadow: var(--shadow-md); } .header-content { max-width: 1200px; margin: 0 auto; - padding: 0 24px; + padding: 0 var(--spacing-lg); display: flex; - flex-direction: column; align-items: center; - justify-content: center; - text-align: center; - gap: 20px; + justify-content: space-between; + gap: var(--spacing-lg); +} + +@media (max-width: 768px) { + .header-content { + flex-direction: column; + text-align: center; + } } .python-logo { @@ -56,28 +378,76 @@ body { } .header-text .subtitle { - margin: 8px 0 0 0; - font-size: 1.1em; + margin: var(--spacing-sm) 0 0 0; + font-size: var(--font-size-lg); color: rgba(255, 255, 255, 0.9); font-weight: 300; } +.header-actions { + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +@media (max-width: 768px) { + .header-actions { + width: 100%; + flex-direction: column; + } +} + .header-search { - width: 100%; + flex: 1; + min-width: 300px; max-width: 500px; } +@media (max-width: 768px) { + .header-search { + width: 100%; + max-width: none; + } +} + +/* Icon Buttons */ +.icon-btn { + width: 44px; + height: 44px; + background: rgba(255, 255, 255, 0.15); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all var(--transition-fast); + font-size: var(--font-size-xl); + backdrop-filter: blur(10px); +} + +.icon-btn:hover { + background: rgba(255, 255, 255, 0.25); + border-color: rgba(255, 255, 255, 0.4); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.icon-btn:active { + transform: translateY(0); +} + .header-search #search-input { width: 100%; padding: 12px 20px; border: 2px solid rgba(255, 255, 255, 0.2); - border-radius: 25px; - font-size: 16px; + border-radius: var(--radius-full); + font-size: var(--font-size-lg); font-family: inherit; background: rgba(255, 255, 255, 0.95); - color: #2e3338; - transition: all 0.3s ease; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + color: var(--color-text-primary); + transition: all var(--transition-base); + box-shadow: var(--shadow-md); backdrop-filter: blur(10px); } @@ -90,40 +460,57 @@ body { } .header-search #search-input::placeholder { - color: #6c757d; + color: var(--color-text-muted); } +/* ======================================== + STATS SECTION + ======================================== */ + .stats-section { - background: #ffffff; - padding: 24px 0; - border-bottom: 1px solid #e9ecef; + background: var(--color-bg-secondary); + padding: var(--spacing-lg) 0; + border-bottom: 1px solid var(--color-border); } .stats-container { - max-width: 1200px; + max-width: 1400px; margin: 0 auto; - padding: 0 24px; + padding: 0 var(--spacing-lg); display: grid; grid-template-columns: repeat(3, 1fr); - gap: 20px; + gap: var(--spacing-lg); +} + +@media (max-width: 1200px) { + .stats-container { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .stats-container { + grid-template-columns: 1fr; + } } .stat-card { - background: #ffffff; - border: 1px solid #e9ecef; - border-radius: 12px; - padding: 20px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); display: flex; align-items: flex-start; - gap: 16px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); - transition: all 0.2s ease; + gap: var(--spacing-md); + box-shadow: var(--shadow-sm); + transition: all var(--transition-fast); min-height: 120px; } .stat-card:hover { - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); + box-shadow: var(--shadow-md); transform: translateY(-2px); + border-color: var(--color-border-hover); } .stat-icon { @@ -486,3 +873,699 @@ body { font-size: 12px !important; } } + +/* Tabs */ +.tabs-container { + background: #f8f9fa; + border-bottom: 2px solid #e9ecef; + position: sticky; + top: 0; + z-index: 100; +} + +.tabs { + max-width: 1400px; + margin: 0 auto; + display: flex; + gap: 4px; + padding: 0 24px; +} + +.tab-btn { + background: transparent; + border: none; + padding: 16px 24px; + font-size: 15px; + font-weight: 600; + color: #5a6c7d; + cursor: pointer; + transition: all 0.2s ease; + border-bottom: 3px solid transparent; + font-family: inherit; +} + +.tab-btn:hover { + color: #3776ab; + background: rgba(55, 118, 171, 0.05); +} + +.tab-btn.active { + color: #3776ab; + border-bottom-color: #3776ab; + background: white; +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block !important; +} + +/* Ensure hidden attribute always works for accessibility */ +.tab-content[hidden] { + display: none !important; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Content wrapper for tabs */ +.content-wrapper { + max-width: 1400px; + margin: 0 auto; + padding: 32px 24px; +} + +/* Statistics Table */ +.table-controls { + display: flex; + gap: 12px; + margin-bottom: 24px; + align-items: center; + flex-wrap: wrap; +} + +.table-search { + flex: 1; + min-width: 250px; + padding: 12px 20px; + border: 2px solid #e9ecef; + border-radius: 8px; + font-size: 14px; + font-family: inherit; + transition: all 0.2s ease; +} + +.table-search:focus { + outline: none; + border-color: #3776ab; + box-shadow: 0 0 0 3px rgba(55, 118, 171, 0.1); +} + +.table-sort { + padding: 12px 16px; + border: 2px solid #e9ecef; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + font-family: inherit; + background: white; + cursor: pointer; + transition: all 0.2s ease; +} + +.table-sort:focus { + outline: none; + border-color: #3776ab; +} + +.export-btn { + padding: 12px 20px; + background: #ffd43b; + color: #2e3338; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; +} + +.export-btn:hover { + background: #ffcd02; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(255, 212, 59, 0.3); +} + +.table-container { + background: white; + border-radius: 12px; + border: 1px solid #e9ecef; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.data-table { + width: 100%; + border-collapse: collapse; +} + +.data-table thead { + background: linear-gradient(135deg, #3776ab 0%, #4584bb 100%); + color: white; +} + +.data-table th { + padding: 16px; + text-align: left; + font-weight: 600; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.data-table tbody tr { + border-bottom: 1px solid #f8f9fa; + transition: background 0.15s ease; +} + +.data-table tbody tr:hover { + background: #f8f9fa; +} + +.data-table td { + padding: 16px; + font-size: 14px; + color: #2e3338; +} + +.data-table td.loading { + text-align: center; + color: #5a6c7d; + font-style: italic; + padding: 32px; +} + +.rank-badge { + display: inline-block; + width: 32px; + height: 32px; + line-height: 32px; + text-align: center; + background: #3776ab; + color: white; + border-radius: 50%; + font-weight: 700; + font-size: 12px; +} + +.func-name { + font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; + color: #3776ab; + font-weight: 600; +} + +.file-location { + font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; + color: #5a6c7d; + font-size: 12px; +} + +.percent-bar { + display: flex; + align-items: center; + gap: 8px; +} + +.percent-bar-fill { + flex: 1; + height: 8px; + background: #f8f9fa; + border-radius: 4px; + overflow: hidden; + position: relative; +} + +.percent-bar-value { + height: 100%; + background: linear-gradient(90deg, #3776ab 0%, #4584bb 100%); + border-radius: 4px; + transition: width 0.3s ease; +} + +.percent-text { + font-weight: 600; + color: #3776ab; + min-width: 50px; + text-align: right; +} + +.action-btn { + padding: 6px 12px; + background: #3776ab; + color: white; + border: none; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.action-btn:hover { + background: #2d5aa0; + transform: scale(1.05); +} + +/* Insights Grid */ +.insights-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 24px; + max-width: 1200px; + margin: 0 auto; +} + +@media (max-width: 1024px) { + .insights-grid { + grid-template-columns: 1fr; + } +} + +.insight-card { + background: white; + border-radius: 12px; + border: 1px solid #e9ecef; + padding: 24px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + transition: all 0.2s ease; +} + +.insight-card:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); + transform: translateY(-2px); +} + +.insight-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 2px solid #f8f9fa; +} + +.insight-icon { + font-size: 28px; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #3776ab 0%, #4584bb 100%); + border-radius: 12px; + flex-shrink: 0; +} + +.insight-header h3 { + margin: 0; + color: #2e3338; + font-size: 18px; + font-weight: 600; +} + +.insight-content { + color: #5a6c7d; + line-height: 1.7; + font-size: 14px; +} + +.insight-item { + padding: 12px; + background: #f8f9fa; + border-radius: 8px; + margin-bottom: 12px; + border-left: 4px solid #3776ab; +} + +.insight-item:last-child { + margin-bottom: 0; +} + +.insight-title { + font-weight: 600; + color: #2e3338; + margin-bottom: 4px; +} + +.insight-description { + color: #5a6c7d; + font-size: 13px; + line-height: 1.5; +} + +.insight-metric { + display: inline-block; + padding: 4px 8px; + background: #3776ab; + color: white; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + margin-right: 8px; +} + +/* Modules Tab */ +.module-header { + text-align: center; + margin-bottom: 32px; +} + +.module-header h2 { + color: #2e3338; + font-size: 28px; + font-weight: 600; + margin: 0 0 8px 0; +} + +.module-subtitle { + color: #5a6c7d; + font-size: 16px; + margin: 0; +} + +.modules-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; +} + +@media (max-width: 1024px) { + .modules-grid { + grid-template-columns: 1fr; + } +} + +.module-chart-container { + background: white; + border-radius: 12px; + border: 1px solid #e9ecef; + padding: 24px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.module-list-container { + background: white; + border-radius: 12px; + border: 1px solid #e9ecef; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +/* Export Grid */ +.export-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 24px; +} + +.export-card { + background: white; + border-radius: 12px; + border: 1px solid #e9ecef; + padding: 32px; + text-align: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + transition: all 0.2s ease; +} + +.export-card:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); + transform: translateY(-4px); +} + +.export-icon { + font-size: 48px; + margin-bottom: 16px; +} + +.export-card h3 { + color: #2e3338; + font-size: 18px; + font-weight: 600; + margin: 0 0 12px 0; +} + +.export-card p { + color: #5a6c7d; + font-size: 14px; + line-height: 1.6; + margin: 0 0 24px 0; +} + +.export-action-btn { + padding: 12px 24px; + background: #3776ab; + color: white; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; + width: 100%; +} + +.export-action-btn:hover { + background: #2d5aa0; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(55, 118, 171, 0.3); +} + +/* Scrollbar styling */ +.table-container::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.table-container::-webkit-scrollbar-track { + background: #f8f9fa; +} + +.table-container::-webkit-scrollbar-thumb { + background: #3776ab; + border-radius: 4px; +} + +.table-container::-webkit-scrollbar-thumb:hover { + background: #2d5aa0; +} + +/* Stats Overview Grid */ +.stats-overview-grid { + display: flex; + flex-direction: column; + gap: var(--spacing-xl); + margin-bottom: var(--spacing-xl); +} + +.stats-section-wrapper { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.stats-section-title { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--color-text-primary); + margin: 0; + padding-bottom: var(--spacing-sm); + border-bottom: 2px solid var(--color-border); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 250px)); + gap: var(--spacing-md); +} + +.stat-metric-card { + /* Inherits from .card */ + max-width: 100%; +} + +.stat-metric-label { + font-size: 12px; + color: #5a6c7d; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 6px; +} + +.stat-help-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + background: #e9ecef; + color: #5a6c7d; + font-size: 11px; + font-weight: 700; + cursor: help; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.stat-help-icon:hover { + background: #3776ab; + color: white; + transform: scale(1.1); +} + +.stat-metric-value { + font-size: 28px; + font-weight: 700; + color: #3776ab; + line-height: 1.2; +} + +.stat-metric-description { + font-size: 13px; + color: #5a6c7d; + margin-top: 6px; +} + +/* Module type badges */ +.module-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-left: 8px; + vertical-align: middle; +} + +.module-badge-stdlib { + background: #3776ab; + color: white; +} + +.module-badge-site-packages { + background: #ffd43b; + color: #2e3338; +} + +.module-badge-project { + background: #28a745; + color: white; +} + +.module-badge-other { + background: #e9ecef; + color: #5a6c7d; +} + +/* Profile Info Grid */ +.profile-info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: 24px; +} + +.profile-info-section { + background: var(--color-surface); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + overflow: hidden; + box-shadow: var(--shadow-sm); + transition: all var(--transition-base); + padding: 0; +} + +.profile-info-section:hover { + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.profile-info-section-header { + background: linear-gradient(135deg, #3776ab 0%, #4584bb 100%); + color: white; + padding: 16px 20px; + display: flex; + align-items: center; + gap: 12px; +} + +.profile-info-section-icon { + font-size: 24px; +} + +.profile-info-section-title { + margin: 0; + font-size: 16px; + font-weight: 600; + letter-spacing: 0.3px; +} + +.profile-info-section-content { + padding: 20px; +} + +.profile-info-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 0; + border-bottom: 1px solid #f5f6f7; +} + +.profile-info-row:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.profile-info-row:first-child { + padding-top: 0; +} + +.profile-info-label { + font-size: 13px; + color: #5a6c7d; + font-weight: 500; + display: flex; + align-items: center; + gap: 6px; + min-width: 140px; +} + +.profile-info-value { + font-size: 15px; + color: #2e3338; + font-weight: 600; + text-align: right; + word-break: break-word; + flex: 1; +} + +.profile-info-value-highlight { + color: #3776ab; + font-size: 16px; + font-weight: 700; +} + +.profile-info-value-success { + color: #28a745; + font-weight: 700; +} + +.profile-info-value-warn { + color: #dc3545; + font-weight: 700; +} + +.profile-info-value-mono { + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 13px; + background: #f8f9fa; + padding: 4px 8px; + border-radius: 4px; +} diff --git a/Lib/profiling/sampling/heatmap.css b/Lib/profiling/sampling/heatmap.css new file mode 100644 index 00000000000000..94e0755de1944f --- /dev/null +++ b/Lib/profiling/sampling/heatmap.css @@ -0,0 +1,718 @@ +/* ======================================== + HEATMAP REPORT STYLES - Tachyon Profiler + Uses shared design system from flamegraph + ======================================== */ + +:root { + /* Color palette */ + --color-primary: #3776ab; + --color-primary-dark: #2d5aa0; + --color-accent: #ffd43b; + --color-bg-main: #ffffff; + --color-bg-secondary: #f8f9fa; + --color-surface: #ffffff; + --color-border: #e9ecef; + --color-text-primary: #2e3338; + --color-text-secondary: #5a6c7d; + --color-text-muted: #8c9ba8; + --color-text-inverse: #ffffff; + + /* Common gradients */ + --gradient-primary: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%); + + /* Elevation & effects */ + --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12); + + /* Border radius */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + + /* Spacing system */ + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + + /* Typography */ + --font-family-base: "Source Sans Pro", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif; + --font-family-mono: "SF Mono", "Monaco", "Consolas", "Courier New", monospace; + + /* Animation */ + --transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1); + + /* Code view specific */ + --code-bg: #ffffff; + --code-bg-line: #f8f9fa; + --code-border: #e9ecef; + --code-text: #2e3338; + --code-text-muted: #8c9ba8; + --code-accent: #3776ab; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-family-base); + line-height: 1.6; + color: var(--color-text-primary); + background: var(--color-bg-main); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ======================================== + INDEX PAGE STYLES + ======================================== */ + +.page-wrapper { + min-height: 100vh; + background: var(--gradient-primary); + padding: var(--spacing-lg); +} + +.container { + max-width: 1400px; + margin: 0 auto; + background: var(--color-surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +/* Header */ +.header { + background: var(--gradient-primary); + color: var(--color-text-inverse); + padding: var(--spacing-xl); + text-align: center; +} + +.header-content { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-md); + flex-wrap: wrap; +} + +.python-logo { + width: 60px; + height: 60px; +} + +.header-text h1 { + font-size: 2.5em; + margin-bottom: var(--spacing-sm); + font-weight: 700; + letter-spacing: -0.5px; +} + +.subtitle { + font-size: 1.1em; + opacity: 0.95; + font-weight: 400; +} + +/* Stats Summary Cards */ +.stats-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--spacing-lg); + padding: var(--spacing-xl); + background: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border); +} + +.stat-card { + background: var(--color-surface); + padding: var(--spacing-lg); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + text-align: center; + transition: all var(--transition-base); +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.stat-value { + font-size: 2em; + font-weight: 700; + color: var(--color-primary); + display: block; + margin-bottom: var(--spacing-sm); +} + +.stat-label { + color: var(--color-text-secondary); + font-size: 0.9em; +} + +/* File List Section */ +.file-list { + padding: var(--spacing-xl); +} + +.file-list h2 { + margin-bottom: var(--spacing-lg); + color: var(--color-text-primary); + font-size: 1.8em; + font-weight: 600; +} + +/* Filter Controls */ +.filter-controls { + margin-bottom: var(--spacing-lg); + display: flex; + gap: var(--spacing-md); + flex-wrap: wrap; +} + +.filter-controls input, +.filter-controls select { + padding: 10px 15px; + border: 2px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 1em; + font-family: var(--font-family-base); + transition: all var(--transition-base); +} + +.filter-controls input { + flex: 1; + min-width: 250px; +} + +.filter-controls input:focus, +.filter-controls select:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(55, 118, 171, 0.1); +} + +/* Table Styles */ +table { + width: 100%; + border-collapse: collapse; + background: var(--color-surface); + border-radius: var(--radius-md); + overflow: hidden; +} + +thead { + background: var(--color-bg-secondary); + position: sticky; + top: 0; + z-index: 10; +} + +th { + padding: 15px; + text-align: left; + font-weight: 600; + color: var(--color-text-primary); + border-bottom: 2px solid var(--color-border); + cursor: pointer; + user-select: none; + transition: background var(--transition-base); +} + +th:hover { + background: var(--color-bg-main); +} + +td { + padding: 12px 15px; + border-bottom: 1px solid var(--color-bg-secondary); +} + +tbody tr { + transition: background var(--transition-base); +} + +tbody tr:hover { + background: var(--color-bg-secondary); +} + +/* Links */ +.file-link { + color: var(--color-primary); + text-decoration: none; + font-weight: 500; + transition: color var(--transition-base); +} + +.file-link:hover { + color: var(--color-primary-dark); + text-decoration: underline; +} + +/* Module Badges */ +.module-badge { + display: inline-block; + padding: 4px 10px; + border-radius: var(--radius-sm); + font-size: 0.85em; + font-weight: 500; +} + +.badge-stdlib { + background: #d4edda; + color: #155724; +} + +.badge-site-packages { + background: #cce5ff; + color: #004085; +} + +.badge-project { + background: #fff3cd; + color: #856404; +} + +.badge-other { + background: #e2e3e5; + color: #383d41; +} + +/* Heatmap Bar */ +.heatmap-bar { + display: inline-block; + height: 20px; + background: linear-gradient(90deg, #00d4ff 0%, #ff6b00 100%); + border-radius: var(--radius-sm); + min-width: 2px; + transition: transform var(--transition-base); +} + +.heatmap-bar:hover { + transform: scaleY(1.2); +} + +/* Footer */ +footer { + padding: var(--spacing-lg); + text-align: center; + color: var(--color-text-secondary); + font-size: 0.9em; + background: var(--color-bg-secondary); + border-top: 1px solid var(--color-border); +} + +/* ======================================== + FILE VIEW STYLES (Code Display) + ======================================== */ + +.code-view { + font-family: var(--font-family-mono); + background: var(--color-bg-main); + color: var(--code-text); + min-height: 100vh; +} + +/* Code Header */ +.code-header { + background: var(--gradient-primary); + color: var(--color-text-inverse); + padding: var(--spacing-lg) var(--spacing-xl); + position: sticky; + top: 0; + z-index: 100; + box-shadow: var(--shadow-md); +} + +.code-header-content { + max-width: 1600px; + margin: 0 auto; + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--spacing-md); +} + +.code-header h1 { + font-size: 1.5em; + font-weight: 600; + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.back-link { + color: var(--color-text-inverse); + text-decoration: none; + padding: 8px 16px; + background: rgba(255, 255, 255, 0.15); + border-radius: var(--radius-sm); + font-size: 0.9em; + transition: all var(--transition-base); + font-family: var(--font-family-base); +} + +.back-link:hover { + background: rgba(255, 255, 255, 0.25); +} + +/* File Stats Bar */ +.file-stats { + background: var(--color-bg-secondary); + padding: var(--spacing-lg) var(--spacing-xl); + border-bottom: 1px solid var(--code-border); +} + +.stats-grid { + max-width: 1600px; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--spacing-lg); +} + +.stat-item { + background: var(--color-surface); + padding: var(--spacing-lg); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + text-align: center; + transition: all var(--transition-base); +} + +.stat-item:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.stat-item .stat-value { + font-size: 1.8em; + font-weight: 700; + color: var(--code-accent); +} + +.stat-item .stat-label { + color: var(--code-text-muted); + font-size: 0.85em; + margin-top: 4px; +} + +/* Legend */ +.legend { + background: var(--color-bg-secondary); + padding: 15px var(--spacing-xl); + border-bottom: 1px solid var(--code-border); +} + +.legend-content { + max-width: 1600px; + margin: 0 auto; + display: flex; + align-items: center; + gap: var(--spacing-xl); +} + +.legend-title { + font-weight: 600; + color: var(--color-text-primary); +} + +.legend-gradient { + flex: 1; + max-width: 400px; + height: 30px; + background: linear-gradient(90deg, + rgba(240, 240, 240, 1) 0%, + rgba(0, 150, 255, 0.2) 25%, + rgba(0, 255, 150, 0.3) 50%, + rgba(255, 200, 0, 0.4) 75%, + rgba(255, 100, 0, 0.6) 90%, + rgba(255, 0, 0, 0.75) 100% + ); + border-radius: var(--radius-sm); + border: 1px solid var(--code-border); +} + +.legend-labels { + display: flex; + gap: 15px; + font-size: 0.85em; + color: var(--code-text-muted); +} + +/* Code Container */ +.code-container { + max-width: 1600px; + margin: var(--spacing-lg) auto; + background: var(--color-surface); + border: 1px solid var(--code-border); + border-radius: var(--radius-md); + overflow: hidden; + box-shadow: var(--shadow-sm); +} + +/* Code Lines */ +.code-line { + position: relative; + display: flex; + min-height: 20px; + line-height: 20px; + font-size: 14px; + transition: background var(--transition-base); + scroll-margin-top: 50vh; +} + +.code-line:hover { + filter: brightness(0.98); +} + +.line-number { + flex-shrink: 0; + width: 60px; + padding: 0 10px; + text-align: right; + color: var(--code-text-muted); + background: var(--code-bg-line); + border-right: 1px solid var(--code-border); + user-select: none; + transition: all var(--transition-base); +} + +.line-number:hover { + background: var(--color-primary); + color: var(--color-text-inverse); +} + +.line-samples { + flex-shrink: 0; + width: 100px; + padding: 0 10px; + text-align: right; + color: var(--code-accent); + background: var(--code-bg-line); + border-right: 1px solid var(--code-border); + font-weight: 600; + user-select: none; +} + +.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(--code-border); + border-radius: var(--radius-sm); +} + +.line-content::-webkit-scrollbar-thumb:hover { + background: #4e4e52; +} + +/* 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 8px; + font-size: 11px; + font-weight: 500; + border: 1px solid var(--code-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-primary); + cursor: pointer; + transition: all var(--transition-base); + font-family: var(--font-family-base); + user-select: none; +} + +.nav-btn:hover:not(:disabled) { + background: var(--color-primary); + color: var(--color-text-inverse); + 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(--color-text-muted); + background: var(--color-bg-secondary); + border-color: var(--color-border); +} + +.nav-btn.caller { + color: #2563eb; +} + +.nav-btn.callee { + color: #dc2626; +} + +.nav-btn.caller:hover:not(:disabled) { + background: #2563eb; +} + +.nav-btn.callee:hover:not(:disabled) { + background: #dc2626; +} + +/* 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(--color-accent); + outline-offset: -3px; + } + 50% { + background: rgba(255, 212, 59, 0.5) !important; + outline: 3px solid var(--color-accent); + outline-offset: -3px; + } + 100% { + background: inherit; + outline: 3px solid transparent; + outline-offset: -3px; + } +} + +/* Popup menu for multiple callees */ +.callee-menu { + position: absolute; + background: var(--color-surface); + border: 1px solid var(--code-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + padding: var(--spacing-sm); + z-index: 1000; + min-width: 250px; + max-width: 400px; + max-height: 300px; + overflow-y: auto; +} + +.callee-menu-header { + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: var(--spacing-sm); + padding-bottom: var(--spacing-sm); + border-bottom: 1px solid var(--code-border); +} + +.callee-menu-item { + padding: var(--spacing-sm); + margin: 4px 0; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background var(--transition-base); + display: flex; + flex-direction: column; + gap: 4px; +} + +.callee-menu-item:hover { + background: var(--color-bg-secondary); +} + +.callee-menu-func { + font-weight: 500; + color: var(--color-primary); + font-family: var(--font-family-mono); + font-size: 0.9em; +} + +.callee-menu-file { + font-size: 0.85em; + color: var(--color-text-muted); +} + +/* Callee menu scrollbar styling */ +.callee-menu::-webkit-scrollbar { + width: 8px; +} + +.callee-menu::-webkit-scrollbar-track { + background: var(--color-bg-secondary); + border-radius: var(--radius-sm); +} + +.callee-menu::-webkit-scrollbar-thumb { + background: var(--code-border); + border-radius: var(--radius-sm); + transition: background var(--transition-base); +} + +.callee-menu::-webkit-scrollbar-thumb:hover { + background: var(--color-text-muted); +} + +/* ======================================== + SCROLL MINIMAP MARKER + ======================================== */ + +#scroll_marker { + position: fixed; + z-index: 1000; + right: 0; + top: 0; + width: 16px; + height: 100%; + background: var(--color-surface); + border-left: 1px solid var(--code-border); + will-change: transform; + pointer-events: none; +} + +#scroll_marker .marker { + position: absolute; + min-height: 3px; + width: 100%; + pointer-events: none; +} + +/* Marker colors based on sample intensity */ +#scroll_marker .marker.cold { + background: rgba(100, 150, 255, 0.4); +} + +#scroll_marker .marker.warm { + background: rgba(0, 200, 100, 0.5); +} + +#scroll_marker .marker.hot { + background: rgba(255, 150, 0, 0.6); +} + +#scroll_marker .marker.vhot { + background: rgba(255, 50, 0, 0.8); +} diff --git a/Lib/profiling/sampling/heatmap.js b/Lib/profiling/sampling/heatmap.js new file mode 100644 index 00000000000000..dfdd7e3fb0c30f --- /dev/null +++ b/Lib/profiling/sampling/heatmap.js @@ -0,0 +1,184 @@ +// Tachyon Profiler - Heatmap JavaScript +// Interactive features for the heatmap visualization + +// Apply background colors on page load +document.addEventListener('DOMContentLoaded', function() { + // 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; + } + }); +}); + +// State management +let currentMenu = null; + +// Utility: Create element with class and content +function createElement(tag, className, textContent = '') { + const el = document.createElement(tag); + if (className) el.className = className; + if (textContent) el.textContent = textContent; + return el; +} + +// Utility: Calculate smart menu position +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 }; +} + +// Close and remove current menu +function closeMenu() { + if (currentMenu) { + currentMenu.remove(); + currentMenu = null; + } +} + +// Show navigation menu for multiple options +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'); + item.appendChild(createElement('div', 'callee-menu-func', linkData.func)); + 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; +} + +// Handle navigation button clicks +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); + } +} + +// Initialize navigation buttons +document.querySelectorAll('.nav-btn').forEach(button => { + button.addEventListener('click', e => handleNavigationClick(button, e)); +}); + +// Close menu when clicking outside +document.addEventListener('click', e => { + if (currentMenu && !currentMenu.contains(e.target) && !e.target.classList.contains('nav-btn')) { + closeMenu(); + } +}); + +// Scroll to target line (centered using CSS scroll-margin-top) +function scrollToTargetLine() { + if (!window.location.hash) return; + const target = document.querySelector(window.location.hash); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } +} + +// 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()}`; + }); +}); + +// Setup scroll-to-line behavior +setTimeout(scrollToTargetLine, 100); +window.addEventListener('hashchange', () => setTimeout(scrollToTargetLine, 50)); + +// Get sample count from line element +function getSampleCount(line) { + const text = line.querySelector('.line-samples')?.textContent.trim().replace(/,/g, ''); + return parseInt(text) || 0; +} + +// Classify intensity based on ratio +function getIntensityClass(ratio) { + if (ratio > 0.75) return 'vhot'; + if (ratio > 0.5) return 'hot'; + if (ratio > 0.25) return 'warm'; + return 'cold'; +} + +// Build scroll minimap showing hotspot locations +function buildScrollMarker() { + const existing = document.getElementById('scroll_marker'); + if (existing) existing.remove(); + + if (document.body.scrollHeight <= window.innerHeight) return; + + const lines = document.querySelectorAll('.code-line'); + 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); +} + +// Build scroll marker on load and resize +setTimeout(buildScrollMarker, 200); +window.addEventListener('resize', buildScrollMarker); diff --git a/Lib/profiling/sampling/heatmap_index.js b/Lib/profiling/sampling/heatmap_index.js new file mode 100644 index 00000000000000..b575fef98ce570 --- /dev/null +++ b/Lib/profiling/sampling/heatmap_index.js @@ -0,0 +1,63 @@ +function filterTable() { + const searchTerm = document.getElementById('searchInput').value.toLowerCase(); + const moduleFilter = document.getElementById('moduleFilter').value; + const table = document.getElementById('fileTable'); + const rows = table.getElementsByTagName('tr'); + + for (let i = 1; i < rows.length; i++) { + const row = rows[i]; + const text = row.textContent.toLowerCase(); + const moduleType = row.getAttribute('data-module-type'); + + const matchesSearch = text.includes(searchTerm); + const matchesModule = moduleFilter === 'all' || moduleType === moduleFilter; + + row.style.display = (matchesSearch && matchesModule) ? '' : 'none'; + } +} + +// Track current sort state +let currentSortColumn = -1; +let currentSortAscending = true; + +function sortTable(columnIndex) { + const table = document.getElementById('fileTable'); + const tbody = table.querySelector('tbody'); + const rows = Array.from(tbody.querySelectorAll('tr')); + + // Determine sort direction + let ascending = true; + if (currentSortColumn === columnIndex) { + // Same column - toggle direction + ascending = !currentSortAscending; + } else { + // New column - default direction based on type + // For numeric columns (samples, lines, %), descending is default + // For text columns (file, module, type), ascending is default + ascending = columnIndex <= 2; // Columns 0-2 are text, 3+ are numeric + } + + rows.sort((a, b) => { + let aVal = a.cells[columnIndex].textContent.trim(); + let bVal = b.cells[columnIndex].textContent.trim(); + + // Try to parse as number + const aNum = parseFloat(aVal.replace(/,/g, '').replace('%', '')); + const bNum = parseFloat(bVal.replace(/,/g, '').replace('%', '')); + + let result; + if (!isNaN(aNum) && !isNaN(bNum)) { + result = aNum - bNum; // Numeric comparison + } else { + result = aVal.localeCompare(bVal); // String comparison + } + + return ascending ? result : -result; + }); + + rows.forEach(row => tbody.appendChild(row)); + + // Update sort state + currentSortColumn = columnIndex; + currentSortAscending = ascending; +} diff --git a/Lib/profiling/sampling/heatmap_index_template.html b/Lib/profiling/sampling/heatmap_index_template.html new file mode 100644 index 00000000000000..05238bdaa1598c --- /dev/null +++ b/Lib/profiling/sampling/heatmap_index_template.html @@ -0,0 +1,80 @@ + + + + + + Tachyon Profiler - Heatmap Report + + + +
+
+
+
+ +
+

Tachyon Profiler Heatmap Report

+

Line-by-line performance analysis

+
+
+
+ +
+
+ + Files Profiled +
+
+ + Total Snapshots +
+
+ + Duration +
+
+ + Samples/sec +
+
+ +
+

Profiled Files

+ +
+ + +
+ + + + + + + + + + + + + + + +
File ⇅Module ⇅Type ⇅Line Samples ⇅Lines Hit ⇅Intensity
+
+ + +
+
+ + + + diff --git a/Lib/profiling/sampling/heatmap_pyfile_template.html b/Lib/profiling/sampling/heatmap_pyfile_template.html new file mode 100644 index 00000000000000..8f94cc9b0b49a0 --- /dev/null +++ b/Lib/profiling/sampling/heatmap_pyfile_template.html @@ -0,0 +1,56 @@ + + + + + + <!-- FILENAME --> - Heatmap + + + +
+
+

📄

+ ← Back to Index +
+
+ +
+
+
+
+
Total Samples
+
+
+
+
Lines Hit
+
+
+
%
+
% of Total
+
+
+
+
Max Samples/Line
+
+
+
+ +
+
+ 🔥 Intensity: +
+
+ Cold (0) + + Hot (Max) +
+
+
+ +
+ +
+ + + + diff --git a/Lib/profiling/sampling/sample.py b/Lib/profiling/sampling/sample.py index 7a0f739a5428c6..98f6d404d34065 100644 --- a/Lib/profiling/sampling/sample.py +++ b/Lib/profiling/sampling/sample.py @@ -12,7 +12,7 @@ from _colorize import ANSIColors from .pstats_collector import PstatsCollector -from .stack_collector import CollapsedStackCollector, FlamegraphCollector +from .stack_collector import CollapsedStackCollector, FlamegraphCollector, HeatmapCollector from .gecko_collector import GeckoCollector _FREE_THREADED_BUILD = sysconfig.get_config_var("Py_GIL_DISABLED") is not None @@ -41,6 +41,7 @@ def _parse_mode(mode_string): - --pstats: Detailed profiling statistics with sorting options - --collapsed: Stack traces for generating flamegraphs - --flamegraph Interactive HTML flamegraph visualization (requires web browser) + - --heatmap: Coverage.py-style HTML heatmap showing line-by-line sample intensity Examples: # Profile process 1234 for 10 seconds with default settings @@ -61,6 +62,9 @@ def _parse_mode(mode_string): # Generate a HTML flamegraph python -m profiling.sampling --flamegraph -p 1234 + # Generate a heatmap report with line-by-line sample intensity + python -m profiling.sampling --heatmap -o results -p 1234 + # Profile all threads, sort by total time python -m profiling.sampling -a --sort-tottime -p 1234 @@ -632,6 +636,9 @@ def sample( case "flamegraph": collector = FlamegraphCollector(skip_idle=skip_idle) filename = filename or f"flamegraph.{pid}.html" + case "heatmap": + collector = HeatmapCollector(skip_idle=skip_idle) + filename = filename or f"heatmap_{pid}" case "gecko": collector = GeckoCollector(skip_idle=skip_idle) filename = filename or f"gecko.{pid}.json" @@ -676,10 +683,13 @@ def _validate_collapsed_format_args(args, parser): f"The following options are only valid with --pstats format: {', '.join(invalid_opts)}" ) - # Set default output filename for collapsed format only if we have a PID + # Set default output filename for the format only if we have a PID # For module/script execution, this will be set later with the subprocess PID if not args.outfile and args.pid is not None: - args.outfile = f"collapsed.{args.pid}.txt" + if args.format == "collapsed": + args.outfile = f"collapsed.{args.pid}.txt" + elif args.format == "heatmap": + args.outfile = f"heatmap_{args.pid}" def wait_for_process_and_sample(pid, sort_value, args): @@ -691,6 +701,10 @@ def wait_for_process_and_sample(pid, sort_value, args): filename = f"collapsed.{pid}.txt" elif args.format == "gecko": filename = f"gecko.{pid}.json" + elif args.format == "flamegraph": + filename = f"flamegraph.{pid}.html" + elif args.format == "heatmap": + filename = f"heatmap_{pid}" mode = _parse_mode(args.mode) @@ -794,6 +808,13 @@ def main(): dest="format", help="Generate HTML flamegraph visualization", ) + output_format.add_argument( + "--heatmap", + action="store_const", + const="heatmap", + dest="format", + help="Generate coverage.py-style HTML heatmap with line-by-line sample intensity", + ) output_format.add_argument( "--gecko", action="store_const", @@ -806,8 +827,8 @@ def main(): "-o", "--outfile", help="Save output to a file (if omitted, prints to stdout for pstats, " - "or saves to collapsed..txt or flamegraph..html for the " - "respective output formats)" + "or saves to collapsed..txt, flamegraph..html, or heatmap_/ " + "for the respective output formats)" ) # pstats-specific options @@ -879,7 +900,7 @@ def main(): args = parser.parse_args() # Validate format-specific arguments - if args.format in ("collapsed", "gecko"): + if args.format in ("collapsed", "gecko", "heatmap"): _validate_collapsed_format_args(args, parser) sort_value = args.sort if args.sort is not None else 2 diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index bc38151e067989..bf7819827125c3 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -1,3 +1,5 @@ +"""Stack trace collectors for Python profiling with optimized sampling analysis.""" + import base64 import collections import functools @@ -5,62 +7,259 @@ import json import linecache import os +import platform +import site +import sys +from pathlib import Path from .collector import Collector from .string_table import StringTable +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 + + class StackTraceCollector(Collector): + """Base class for stack trace analysis collectors. + + This abstract base class provides common functionality for processing + stack traces from profiling data. Subclasses should implement the + process_frames method to handle the actual analysis. + + Args: + skip_idle: If True, skip idle threads in frame processing + """ + def __init__(self, *, skip_idle=False): + """Initialize the collector with optional idle thread skipping.""" self.skip_idle = skip_idle def collect(self, stack_frames, skip_idle=False): - for frames, thread_id in self._iter_all_frames(stack_frames, skip_idle=skip_idle): + """Collect and process stack frames from profiling data. + + Args: + stack_frames: Stack frame data from profiler + skip_idle: Override instance setting for skipping idle frames + """ + effective_skip_idle = skip_idle if skip_idle is not None else self.skip_idle + + for frames, thread_id in self._iter_all_frames(stack_frames, skip_idle=effective_skip_idle): if not frames: continue - self.process_frames(frames, thread_id) + + try: + self.process_frames(frames, thread_id) + except Exception: + # Silently continue processing other frames if one fails + pass def process_frames(self, frames, thread_id): - pass + """Process a single set of stack frames. + + This method should be implemented by subclasses to perform + the actual analysis of the stack frames. + + Args: + frames: List of frame tuples (filename, lineno, funcname) + thread_id: ID of the thread these frames came from + """ + raise NotImplementedError("Subclasses must implement process_frames") class CollapsedStackCollector(StackTraceCollector): + """Collector that generates collapsed stack traces for flame graph generation. + + This collector aggregates stack traces by counting identical call stacks, + producing output suitable for tools like FlameGraph. + """ + def __init__(self, *args, **kwargs): + """Initialize with a counter for stack traces.""" super().__init__(*args, **kwargs) self.stack_counter = collections.Counter() def process_frames(self, frames, thread_id): + """Process frames by building a collapsed stack representation. + + Args: + frames: List of frame tuples (filename, lineno, funcname) + thread_id: Thread ID for this stack trace + """ + # Reverse frames to get root->leaf order for collapsed stacks call_tree = tuple(reversed(frames)) self.stack_counter[(call_tree, thread_id)] += 1 def export(self, filename): + """Export collapsed stacks to a file. + + Args: + filename: Path where to write the collapsed stack output + """ + if not self.stack_counter: + print("Warning: No stack data to export") + return + lines = [] for (call_tree, thread_id), count in self.stack_counter.items(): + # Format as semicolon-separated stack with thread info stack_str = ";".join( - f"{os.path.basename(f[0])}:{f[2]}:{f[1]}" for f in call_tree + f"{os.path.basename(frame[0])}:{frame[2]}:{frame[1]}" + for frame in call_tree ) lines.append((f"tid:{thread_id};{stack_str}", count)) + # Sort by count (descending) then by stack string for deterministic output lines.sort(key=lambda x: (-x[1], x[0])) - with open(filename, "w") as f: - for stack, count in lines: - f.write(f"{stack} {count}\n") - print(f"Collapsed stack output written to {filename}") + try: + with open(filename, "w", encoding='utf-8') as f: + for stack, count in lines: + f.write(f"{stack} {count}\n") + print(f"Collapsed stack output written to {filename}") + except OSError as e: + print(f"Error: Failed to write collapsed stack output: {e}") + raise class FlamegraphCollector(StackTraceCollector): + """Collector that generates interactive flame graph visualizations. + + This collector builds a hierarchical representation of stack traces + and generates self-contained HTML flame graphs using D3.js. + """ + def __init__(self, *args, **kwargs): + """Initialize the flame graph collector.""" super().__init__(*args, **kwargs) self.stats = {} self._root = {"samples": 0, "children": {}, "threads": set()} self._total_samples = 0 - self._func_intern = {} + self._func_intern = {} # Function interning for memory efficiency self._string_table = StringTable() self._all_threads = set() def set_stats(self, sample_interval_usec, duration_sec, sample_rate, error_rate=None): - """Set profiling statistics to include in flamegraph data.""" + """Set profiling statistics to include in flame graph data. + + 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 + """ self.stats = { "sample_interval_usec": sample_interval_usec, "duration_sec": duration_sec, @@ -68,40 +267,77 @@ def set_stats(self, sample_interval_usec, duration_sec, sample_rate, error_rate= "error_rate": error_rate } + def process_frames(self, frames, thread_id): + """Process frames by building a hierarchical call tree. + + Args: + frames: List of frame tuples (filename, lineno, funcname) + thread_id: Thread ID for this stack trace + """ + # Reverse to root->leaf order for tree building + call_tree = reversed(frames) + self._root["samples"] += 1 + self._total_samples += 1 + self._root["threads"].add(thread_id) + self._all_threads.add(thread_id) + + current = self._root + for func in call_tree: + # Intern function tuples to save memory + func = self._func_intern.setdefault(func, func) + children = current["children"] + node = children.get(func) + if node is None: + node = {"samples": 0, "children": {}, "threads": set()} + children[func] = node + node["samples"] += 1 + node["threads"].add(thread_id) + current = node + def export(self, filename): - flamegraph_data = self._convert_to_flamegraph_format() + """Export flame graph as a self-contained HTML file. - # Debug output with string table statistics + Args: + filename: Path where to write the HTML flame graph + """ + try: + flamegraph_data = self._convert_to_flamegraph_format() + self._print_export_stats(flamegraph_data) + + if not flamegraph_data.get("children"): + print("Warning: No functions found in profiling data. Check if sampling captured any data.") + return + + html_content = self._create_flamegraph_html(flamegraph_data) + + with open(filename, "w", encoding="utf-8") as f: + f.write(html_content) + + print(f"Flamegraph saved to: {filename}") + + except Exception as e: + print(f"Error: Failed to export flame graph: {e}") + raise + + def _print_export_stats(self, flamegraph_data): num_functions = len(flamegraph_data.get("children", [])) total_time = flamegraph_data.get("value", 0) string_count = len(self._string_table) print( - f"Flamegraph data: {num_functions} root functions, total samples: {total_time}, " - f"{string_count} unique strings" + f"Flamegraph data: {num_functions} root functions, " + f"total samples: {total_time}, {string_count} unique strings" ) - if num_functions == 0: - print( - "Warning: No functions found in profiling data. Check if sampling captured any data." - ) - return - - html_content = self._create_flamegraph_html(flamegraph_data) - - with open(filename, "w", encoding="utf-8") as f: - f.write(html_content) - - print(f"Flamegraph saved to: {filename}") - @staticmethod @functools.lru_cache(maxsize=None) def _format_function_name(func): filename, lineno, funcname = func + # Optimize path display for long filenames if len(filename) > 50: - parts = filename.split("/") - if len(parts) > 2: - filename = f".../{'/'.join(parts[-2:])}" + path_parts = filename.split("/") + if len(path_parts) > 2: + filename = f".../{'/'.join(path_parts[-2:])}" return f"{funcname} ({filename}:{lineno})" @@ -188,26 +424,6 @@ def convert_children(children, min_samples): "strings": self._string_table.get_strings() } - def process_frames(self, frames, thread_id): - # Reverse to root->leaf - call_tree = reversed(frames) - self._root["samples"] += 1 - self._total_samples += 1 - self._root["threads"].add(thread_id) - self._all_threads.add(thread_id) - - current = self._root - for func in call_tree: - func = self._func_intern.setdefault(func, func) - children = current["children"] - node = children.get(func) - if node is None: - node = {"samples": 0, "children": {}, "threads": set()} - children[func] = node - node["samples"] += 1 - node["threads"].add(thread_id) - current = node - def _get_source_lines(self, func): filename, lineno, _ = func @@ -228,62 +444,492 @@ def _get_source_lines(self, func): return None def _create_flamegraph_html(self, data): - data_json = json.dumps(data) + """Create self-contained HTML for the flame graph visualization. - template_dir = importlib.resources.files(__package__) - vendor_dir = template_dir / "_vendor" - assets_dir = template_dir / "_assets" + Args: + data: Flame graph data structure - d3_path = vendor_dir / "d3" / "7.8.5" / "d3.min.js" - d3_flame_graph_dir = vendor_dir / "d3-flame-graph" / "4.1.3" - fg_css_path = d3_flame_graph_dir / "d3-flamegraph.css" - fg_js_path = d3_flame_graph_dir / "d3-flamegraph.min.js" - fg_tooltip_js_path = d3_flame_graph_dir / "d3-flamegraph-tooltip.min.js" + Returns: + str: Complete HTML content with inlined assets + """ + data_json = json.dumps(data) + template_dir = importlib.resources.files(__package__) + # Load base template and assets html_template = (template_dir / "flamegraph_template.html").read_text(encoding="utf-8") + html_template = self._inline_first_party_assets(html_template, template_dir) + html_template = self._inline_vendor_assets(html_template, template_dir) + html_template = self._inline_logo(html_template, template_dir) + + # Replace data placeholder + return html_template.replace("{{FLAMEGRAPH_DATA}}", data_json) + + def _inline_first_party_assets(self, html_template, template_dir): css_content = (template_dir / "flamegraph.css").read_text(encoding="utf-8") js_content = (template_dir / "flamegraph.js").read_text(encoding="utf-8") - # Inline first-party CSS/JS html_template = html_template.replace( "", f"" ) html_template = html_template.replace( "", f"" ) + return html_template - png_path = assets_dir / "python-logo-only.png" - b64_logo = base64.b64encode(png_path.read_bytes()).decode("ascii") + def _inline_vendor_assets(self, html_template, template_dir): + vendor_dir = template_dir / "_vendor" + d3_flame_graph_dir = vendor_dir / "d3-flame-graph" / "4.1.3" - # Let CSS control size; keep markup simple + # Load vendor assets + d3_js = (vendor_dir / "d3" / "7.8.5" / "d3.min.js").read_text(encoding="utf-8") + fg_css = (d3_flame_graph_dir / "d3-flamegraph.css").read_text(encoding="utf-8") + fg_js = (d3_flame_graph_dir / "d3-flamegraph.min.js").read_text(encoding="utf-8") + fg_tooltip_js = (d3_flame_graph_dir / "d3-flamegraph-tooltip.min.js").read_text(encoding="utf-8") + + # Inline vendor assets + replacements = [ + ("", f""), + ("", f""), + ("", f""), + ("", f""), + ] + + for placeholder, replacement in replacements: + html_template = html_template.replace(placeholder, replacement) + + return html_template + + def _inline_logo(self, html_template, template_dir): + png_path = template_dir / "_assets" / "python-logo-only.png" + b64_logo = base64.b64encode(png_path.read_bytes()).decode("ascii") logo_html = f'Python logo' - html_template = html_template.replace("", logo_html) + return html_template.replace("", logo_html) - d3_js = d3_path.read_text(encoding="utf-8") - fg_css = fg_css_path.read_text(encoding="utf-8") - fg_js = fg_js_path.read_text(encoding="utf-8") - fg_tooltip_js = fg_tooltip_js_path.read_text(encoding="utf-8") - html_template = html_template.replace( - "", - f"", - ) - html_template = html_template.replace( - "", - f"", - ) - html_template = html_template.replace( - "", - f"", - ) - html_template = html_template.replace( - "", - f"", +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. + """ + + 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() # (filename, lineno) -> count + self.file_samples = collections.defaultdict(collections.Counter) # filename -> {lineno: count} + + # Call graph data structures for navigation + self.call_graph = collections.defaultdict(list) # (caller_file, caller_line) -> [callees] + self.callers_graph = collections.defaultdict(list) # (callee_file, callee_line) -> [callers] + self.function_definitions = {} # (filename, funcname) -> lineno + + # Statistics and metadata + self._total_samples = 0 + self._path_info = get_python_path_info() + self.stats = {} + + def set_stats(self, sample_interval_usec, duration_sec, sample_rate, error_rate=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 + **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, + "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) + 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 + + self._record_line_sample(filename, lineno, funcname) + + # 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): + # Skip internal or invalid files + if not filename or filename.startswith('<') or filename.startswith('['): + return False + + # Skip invalid frames with corrupted filename data + # These come from C code when frame info is unavailable + if filename == "__init__" and lineno == 0: + return False + + return True + + def _record_line_sample(self, filename, lineno, funcname): + self.line_samples[(filename, lineno)] += 1 + self.file_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): + 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 ) - # Replace the placeholder with actual data - html_content = html_template.replace( - "{{FLAMEGRAPH_DATA}}", data_json + # 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) + + 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): + output_dir = Path(output_path) + if output_dir.suffix == '.html': + output_dir = output_dir.with_suffix('') + + output_dir.mkdir(exist_ok=True) + return output_dir + + def _create_file_index(self, file_stats): + self.file_index = { + stat['filename']: f"file_{i:04d}.html" + for i, stat in enumerate(file_stats) + } + + def _generate_file_reports(self, output_dir, file_stats): + for stat in file_stats: + file_path = output_dir / self.file_index[stat['filename']] + self._generate_file_html( + file_path, + stat['filename'], + self.file_samples[stat['filename']], + stat + ) + + def _print_export_summary(self, output_dir, file_stats): + 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): + """Calculate statistics for each file.""" + file_stats = [] + for filename, line_counts in self.file_samples.items(): + total_samples = sum(line_counts.values()) + num_lines = len(line_counts) + max_samples = max(line_counts.values()) if line_counts else 0 + module_name, module_type = extract_module_name(filename, self._path_info) + + file_stats.append({ + 'filename': filename, + 'module_name': module_name, + 'module_type': module_type, + 'total_samples': total_samples, + 'num_lines': num_lines, + 'max_samples': max_samples, + 'percentage': 0 # Will be calculated after sorting + }) + + # 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, file_stats): + """Generate index.html with list of all profiled files.""" + import html + import base64 + import importlib.resources + + # Load template and assets + template_dir = importlib.resources.files(__package__) + assets_dir = template_dir / "_assets" + + template_content = (template_dir / "heatmap_index_template.html").read_text(encoding="utf-8") + css_content = (template_dir / "heatmap.css").read_text(encoding="utf-8") + js_content = (template_dir / "heatmap_index.js").read_text(encoding="utf-8") + + # Load Python logo + png_path = assets_dir / "python-logo-only.png" + b64_logo = base64.b64encode(png_path.read_bytes()).decode("ascii") + logo_html = f'' + + # Build table rows + table_rows = self._build_table_rows(file_stats) + + # Populate template + replacements = { + "": f"", + "": f"", + "": logo_html, + "": str(len(file_stats)), + "": f"{self._total_samples:,}", + "": f"{self.stats.get('duration_sec', 0):.1f}s", + "": f"{self.stats.get('sample_rate', 0):.1f}", + "": ''.join(table_rows), + } + + html_content = template_content + for placeholder, value in replacements.items(): + html_content = html_content.replace(placeholder, value) + + index_path.write_text(html_content, encoding='utf-8') + + def _build_table_rows(self, file_stats): + """Build HTML table rows for the file listing.""" + import html + + table_rows = [] + for stat in file_stats: + full_path = html.escape(stat['filename']) + module_name = html.escape(stat['module_name']) + module_type = stat['module_type'] + badge_class = f"badge-{module_type}" + + # Calculate heatmap color for intensity visualization + intensity = stat['percentage'] / 100.0 + r, g, b, alpha = self._calculate_intensity_color(intensity) + bg_color = f"rgba({r}, {g}, {b}, {alpha})" + bar_width = stat['percentage'] # Use actual percentage for bar width + + html_file = self.file_index[stat['filename']] + row = f''' + + {full_path} + {module_name} + {module_type} + {stat['total_samples']:,} + {stat['num_lines']} + +
+ + ''' + table_rows.append(row) + return table_rows + + def _calculate_intensity_color(self, intensity): + """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 + """ + # Color stops with (threshold, rgb_func, alpha_func) + stops = [ + (0.2, lambda i: (0, int(150 * i * 5), 255), lambda i: 0.3), + (0.4, lambda i: (0, 255, int(255 * (1 - (i - 0.2) * 5))), lambda i: 0.4), + (0.6, lambda i: (int(255 * (i - 0.4) * 5), 255, 0), lambda i: 0.5), + (0.8, lambda i: (255, int(200 - 100 * (i - 0.6) * 5), 0), lambda i: 0.6), + (1.0, lambda i: (255, int(100 * (1 - (i - 0.8) * 5)), 0), lambda i: 0.7 + 0.15 * (i - 0.8) * 5), + ] + + for threshold, rgb_func, alpha_func in stops: + if intensity < threshold or threshold == 1.0: + r, g, b = rgb_func(intensity) + return (r, g, b, alpha_func(intensity)) + + # Fallback (should not reach here) + return (255, 0, 0, 0.75) + + def _deduplicate_by_function(self, items): + """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, btn_class, arrow): + """Create HTML for a navigation button (caller/callee). + + Args: + items: List of (file, line, func) tuples + btn_class: CSS class ('caller' or 'callee') + arrow: Arrow symbol ('↑' or '↓') + + Returns: + HTML string for button, or empty string if no valid items + """ + import html + import json + import os + + # Filter valid items (must be in index and have valid line number) + valid_items = [(f, l, fn) for f, l, fn in items if f in self.file_index and l > 0] + if not valid_items: + return "" + + if len(valid_items) == 1: + file, line, func = 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)}" + return f'' + + # Multiple items - create menu + items_data = [ + { + 'file': os.path.basename(file), + 'func': func, + 'link': f"{self.file_index[file]}#line-{line}" + } + for file, line, func in valid_items + ] + items_json = html.escape(json.dumps(items_data)) + title = f"{len(items_data)} {btn_class}s" + return f'' + + def _generate_file_html(self, output_path, filename, line_counts, file_stat): + """Generate HTML for a single source file with heatmap coloring.""" + import html + import importlib.resources + + # Load template, CSS, and JS + template_dir = importlib.resources.files(__package__) + template_content = (template_dir / "heatmap_pyfile_template.html").read_text(encoding="utf-8") + css_content = (template_dir / "heatmap.css").read_text(encoding="utf-8") + js_content = (template_dir / "heatmap.js").read_text(encoding="utf-8") + + # Read source file + try: + source_lines = Path(filename).read_text(encoding='utf-8', errors='replace').splitlines() + except (IOError, OSError): + source_lines = [] + + # Generate HTML for each line + max_samples = max(line_counts.values()) if line_counts else 1 + code_lines_html = [ + self._build_line_html(line_num, line_content, line_counts, max_samples, filename) + for line_num, line_content in enumerate(source_lines, start=1) + ] + + # Populate template + replacements = { + "": html.escape(filename), + "": f"{file_stat['total_samples']:,}", + "": str(file_stat['num_lines']), + "": f"{file_stat['percentage']:.2f}", + "": str(file_stat['max_samples']), + "": ''.join(code_lines_html), + "": f"", + "": f"", + } + + html_content = template_content + for placeholder, value in replacements.items(): + html_content = html_content.replace(placeholder, value) + + output_path.write_text(html_content, encoding='utf-8') + + def _build_line_html(self, line_num, line_content, line_counts, max_samples, filename): + """Build HTML for a single line of source code.""" + import html + + samples = line_counts.get(line_num, 0) + + # Calculate color and sample display + if samples > 0: + intensity = samples / max_samples + r, g, b, alpha = self._calculate_intensity_color(intensity) + bg_color = f"rgba({r}, {g}, {b}, {alpha})" + samples_display = f"{samples:,}" + tooltip = f"{samples:,} samples" + else: + bg_color = "transparent" + samples_display = "" + tooltip = "" + + # Get navigation data + line_key = (filename, line_num) + callers = self._deduplicate_by_function(self.callers_graph.get(line_key, [])) + callees = self._deduplicate_by_function(self.call_graph.get(line_key, [])) + + # Build navigation buttons + caller_btn = self._create_navigation_button(callers, 'caller', '↑') + callee_btn = self._create_navigation_button(callees, 'callee', '↓') + nav_buttons_html = f'
{caller_btn}{callee_btn}
' if (caller_btn or callee_btn) else '' + + # Build line HTML + line_html = html.escape(line_content.rstrip('\n')) + title_attr = f' title="{html.escape(tooltip)}"' if tooltip else "" + + return ( + f'
\n' + f'
{line_num}
\n' + f'
{samples_display}
\n' + f'
{line_html}
\n' + f' {nav_buttons_html}\n' + f'
\n' ) - return html_content diff --git a/Lib/test/test_profiling/test_sampling_profiler.py b/Lib/test/test_profiling/test_sampling_profiler.py index 59bc18b9bcf14d..d0754ffb33c900 100644 --- a/Lib/test/test_profiling/test_sampling_profiler.py +++ b/Lib/test/test_profiling/test_sampling_profiler.py @@ -18,6 +18,7 @@ from profiling.sampling.stack_collector import ( CollapsedStackCollector, FlamegraphCollector, + HeatmapCollector, ) from profiling.sampling.gecko_collector import GeckoCollector @@ -668,6 +669,97 @@ def test_gecko_collector_export(self): self.assertIn("func2", string_array) self.assertIn("other_func", string_array) + def test_heatmap_collector_basic(self): + """Test basic HeatmapCollector functionality.""" + collector = HeatmapCollector() + + # 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() + + # 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("", content.lower()) + self.assertIn("