https://github.com/python/cpython/commit/ff7f62eb2333ac2a2ce2726ba1763bf2fa1956e2
commit: ff7f62eb2333ac2a2ce2726ba1763bf2fa1956e2
branch: main
author: Hugo van Kemenade <[email protected]>
committer: pablogsal <[email protected]>
date: 2025-12-22T14:15:57Z
summary:

gh-142927: Tachyon: Comma separate thousands and fix singular/plurals (#142934)

files:
A Lib/profiling/sampling/_format_utils.py
M Lib/profiling/sampling/_heatmap_assets/heatmap.js
M Lib/profiling/sampling/cli.py
M Lib/profiling/sampling/heatmap_collector.py
M Lib/profiling/sampling/sample.py

diff --git a/Lib/profiling/sampling/_format_utils.py 
b/Lib/profiling/sampling/_format_utils.py
new file mode 100644
index 00000000000000..237a4f4186bf20
--- /dev/null
+++ b/Lib/profiling/sampling/_format_utils.py
@@ -0,0 +1,5 @@
+import locale
+
+
+def fmt(value: int | float, decimals: int = 1) -> str:
+    return locale.format_string(f'%.{decimals}f', value, grouping=True)
diff --git a/Lib/profiling/sampling/_heatmap_assets/heatmap.js 
b/Lib/profiling/sampling/_heatmap_assets/heatmap.js
index 90b5b111d36a8f..53928b7b20fb11 100644
--- a/Lib/profiling/sampling/_heatmap_assets/heatmap.js
+++ b/Lib/profiling/sampling/_heatmap_assets/heatmap.js
@@ -577,10 +577,12 @@ function populateBytecodePanel(panel, button) {
         else if (specPct >= 33) specClass = 'medium';
 
         // Build specialization summary
+        const instruction_word = instructions.length === 1 ? 'instruction' : 
'instructions';
+        const sample_word = totalSamples === 1 ? 'sample' : 'samples';
         let html = `<div class="bytecode-spec-summary ${specClass}">
             <span class="spec-pct">${specPct}%</span>
             <span class="spec-label">specialized</span>
-            <span 
class="spec-detail">(${specializedCount}/${instructions.length} instructions, 
${specializedSamples.toLocaleString()}/${totalSamples.toLocaleString()} 
samples)</span>
+            <span 
class="spec-detail">(${specializedCount}/${instructions.length} 
${instruction_word}, 
${specializedSamples.toLocaleString()}/${totalSamples.toLocaleString()} 
${sample_word})</span>
         </div>`;
 
         html += '<div class="bytecode-header">' +
diff --git a/Lib/profiling/sampling/cli.py b/Lib/profiling/sampling/cli.py
index 554167e43f5ed8..aacec645c3479c 100644
--- a/Lib/profiling/sampling/cli.py
+++ b/Lib/profiling/sampling/cli.py
@@ -2,6 +2,7 @@
 
 import argparse
 import importlib.util
+import locale
 import os
 import selectors
 import socket
@@ -634,6 +635,16 @@ def _validate_args(args, parser):
 
 def main():
     """Main entry point for the CLI."""
+    # Set locale for number formatting, restore on exit
+    old_locale = locale.setlocale(locale.LC_ALL, None)
+    locale.setlocale(locale.LC_ALL, "")
+    try:
+        _main()
+    finally:
+        locale.setlocale(locale.LC_ALL, old_locale)
+
+
+def _main():
     # Create the main parser
     parser = argparse.ArgumentParser(
         description=_HELP_DESCRIPTION,
diff --git a/Lib/profiling/sampling/heatmap_collector.py 
b/Lib/profiling/sampling/heatmap_collector.py
index e6701901aa385c..d9dabd664b3245 100644
--- a/Lib/profiling/sampling/heatmap_collector.py
+++ b/Lib/profiling/sampling/heatmap_collector.py
@@ -5,6 +5,7 @@
 import html
 import importlib.resources
 import json
+import locale
 import math
 import os
 import platform
@@ -15,6 +16,7 @@
 from typing import Dict, List, Tuple
 
 from ._css_utils import get_combined_css
+from ._format_utils import fmt
 from .collector import normalize_location, extract_lineno
 from .stack_collector import StackTraceCollector
 
@@ -343,7 +345,7 @@ def render_hierarchical_html(self, trees: Dict[str, 
TreeNode]) -> str:
   <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} {file_word}, {tree.samples:,} 
{sample_word})</span>
+    <span class="type-stats">({tree.count} {file_word}, {tree.samples:n} 
{sample_word})</span>
   </div>
   <div class="type-content"{content_style}>
 '''
@@ -390,7 +392,7 @@ def _render_folder(self, node: TreeNode, name: str, level: 
int = 1) -> str:
         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">'
-                     f'({node.count} {file_word}, {node.samples:,} 
{sample_word})</span>')
+                     f'({node.count} {file_word}, {node.samples:n} 
{sample_word})</span>')
         parts.append(f'{indent}  </div>')
         parts.append(f'{indent}  <div class="folder-content" style="display: 
none;">')
 
@@ -431,10 +433,11 @@ def _render_file_item(self, stat: FileStats, indent: str 
= '') -> str:
         bar_width = min(stat.percentage, 100)
 
         html_file = self.file_index[stat.filename]
+        s = "" if stat.total_samples == 1 else "s"
 
         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}  <span class="file-samples">{stat.total_samples:n} 
sample{s}</span>\n'
                 f'{indent}  <div class="heatmap-bar-container"><div 
class="heatmap-bar" style="width: {bar_width}px; height: 
{self.heatmap_bar_height}px;" data-intensity="{intensity:.3f}"></div></div>\n'
                 f'{indent}</div>\n')
 
@@ -761,7 +764,8 @@ 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")
+        s = "" if len(file_stats) == 1 else "s"
+        print(f"  - {len(file_stats)} source file{s} analyzed")
 
     def _calculate_file_stats(self) -> List[FileStats]:
         """Calculate statistics for each file.
@@ -824,7 +828,7 @@ def _generate_index_html(self, index_path: Path, 
file_stats: List[FileStats]):
         # 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_str = f"{fmt(error_rate)}%"
             error_rate_width = min(error_rate, 100)
             # Determine bar color class based on rate
             if error_rate < 5:
@@ -840,7 +844,7 @@ def _generate_index_html(self, index_path: Path, 
file_stats: List[FileStats]):
 
         missed_samples = self.stats.get('missed_samples')
         if missed_samples is not None:
-            missed_samples_str = f"{missed_samples:.1f}%"
+            missed_samples_str = f"{fmt(missed_samples)}%"
             missed_samples_width = min(missed_samples, 100)
             if missed_samples < 5:
                 missed_samples_class = "good"
@@ -859,10 +863,10 @@ def _generate_index_html(self, index_path: Path, 
file_stats: List[FileStats]):
             "<!-- INLINE_JS -->": 
f"<script>\n{self._template_loader.index_js}\n</script>",
             "<!-- PYTHON_LOGO -->": self._template_loader.logo_html,
             "<!-- PYTHON_VERSION -->": 
f"{sys.version_info.major}.{sys.version_info.minor}",
-            "<!-- 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}",
+            "<!-- NUM_FILES -->": f"{len(file_stats):n}",
+            "<!-- TOTAL_SAMPLES -->": f"{self._total_samples:n}",
+            "<!-- DURATION -->": fmt(self.stats.get('duration_sec', 0)),
+            "<!-- SAMPLE_RATE -->": fmt(self.stats.get('sample_rate', 0)),
             "<!-- ERROR_RATE -->": error_rate_str,
             "<!-- ERROR_RATE_WIDTH -->": str(error_rate_width),
             "<!-- ERROR_RATE_CLASS -->": error_rate_class,
@@ -906,12 +910,12 @@ def _generate_file_html(self, output_path: Path, 
filename: str,
         # 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),
+            "<!-- TOTAL_SAMPLES -->": f"{file_stat.total_samples:n}",
+            "<!-- TOTAL_SELF_SAMPLES -->": f"{file_stat.total_self_samples:n}",
+            "<!-- NUM_LINES -->": f"{file_stat.num_lines:n}",
+            "<!-- PERCENTAGE -->": fmt(file_stat.percentage, 2),
+            "<!-- MAX_SAMPLES -->": f"{file_stat.max_samples:n}",
+            "<!-- MAX_SELF_SAMPLES -->": f"{file_stat.max_self_samples:n}",
             "<!-- 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>",
@@ -948,9 +952,9 @@ def _build_line_html(self, line_num: int, line_content: str,
             else:
                 self_intensity = 0
 
-            self_display = f"{self_samples:,}" if self_samples > 0 else ""
-            cumulative_display = f"{cumulative_samples:,}"
-            tooltip = f"Self: {self_samples:,}, Total: {cumulative_samples:,}"
+            self_display = f"{self_samples:n}" if self_samples > 0 else ""
+            cumulative_display = f"{cumulative_samples:n}"
+            tooltip = f"Self: {self_samples:n}, Total: {cumulative_samples:n}"
         else:
             cumulative_intensity = 0
             self_intensity = 0
@@ -1205,7 +1209,7 @@ def _create_navigation_button(self, items_with_counts: 
List[Tuple[str, int, str,
             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)"
+            title = f"Go to {btn_class}: {html.escape(func)} ({count:n} 
samples)"
             return f'<button class="nav-btn {btn_class}" 
data-nav=\'{html.escape(nav_data)}\' title="{title}">{arrow}</button>'
 
         # Multiple items - create menu
@@ -1220,5 +1224,5 @@ def _create_navigation_button(self, items_with_counts: 
List[Tuple[str, int, str,
             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)"
+        title = f"{len(items_data)} {btn_class}s ({total_samples:n} samples)"
         return f'<button class="nav-btn {btn_class}" 
data-nav-multi=\'{items_json}\' title="{title}">{arrow}</button>'
diff --git a/Lib/profiling/sampling/sample.py b/Lib/profiling/sampling/sample.py
index d4c3b577a17c7b..fdb7ac9a2da15a 100644
--- a/Lib/profiling/sampling/sample.py
+++ b/Lib/profiling/sampling/sample.py
@@ -1,6 +1,5 @@
 import _remote_debugging
 import os
-import pstats
 import statistics
 import sys
 import sysconfig
@@ -8,10 +7,6 @@
 from collections import deque
 from _colorize import ANSIColors
 
-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,
     PROFILING_MODE_CPU,
@@ -19,6 +14,7 @@
     PROFILING_MODE_ALL,
     PROFILING_MODE_EXCEPTION,
 )
+from ._format_utils import fmt
 try:
     from .live_collector import LiveStatsCollector
 except ImportError:
@@ -135,9 +131,9 @@ def sample(self, collector, duration_sec=10, *, 
async_aware=False):
         # Don't print stats for live mode (curses is handling display)
         is_live_mode = LiveStatsCollector is not None and 
isinstance(collector, LiveStatsCollector)
         if not is_live_mode:
-            print(f"Captured {num_samples} samples in {running_time:.2f} 
seconds")
-            print(f"Sample rate: {sample_rate:.2f} samples/sec")
-            print(f"Error rate: {error_rate:.2f}%")
+            print(f"Captured {num_samples:n} samples in {fmt(running_time, 2)} 
seconds")
+            print(f"Sample rate: {fmt(sample_rate, 2)} samples/sec")
+            print(f"Error rate: {fmt(error_rate, 2)}")
 
             # Print unwinder stats if stats collection is enabled
             if self.collect_stats:
@@ -151,7 +147,7 @@ def sample(self, collector, duration_sec=10, *, 
async_aware=False):
             print(
                 f"Warning: missed {expected_samples - num_samples} samples "
                 f"from the expected total of {expected_samples} "
-                f"({(expected_samples - num_samples) / expected_samples * 
100:.2f}%)"
+                f"({fmt((expected_samples - num_samples) / expected_samples * 
100, 2)}%)"
             )
 
     def _print_realtime_stats(self):
@@ -185,16 +181,16 @@ def _print_realtime_stats(self):
                 total = hits + partial + misses
                 if total > 0:
                     hit_pct = (hits + partial) / total * 100
-                    cache_stats_str = f" {ANSIColors.MAGENTA}Cache: 
{hit_pct:.1f}% ({hits}+{partial}/{misses}){ANSIColors.RESET}"
+                    cache_stats_str = f" {ANSIColors.MAGENTA}Cache: 
{fmt(hit_pct)}% ({hits}+{partial}/{misses}){ANSIColors.RESET}"
             except RuntimeError:
                 pass
 
         # Clear line and print stats
         print(
             f"\r\033[K{ANSIColors.BOLD_BLUE}Stats:{ANSIColors.RESET} "
-            f"{ANSIColors.YELLOW}{mean_hz:.1f}Hz 
({mean_us_per_sample:.1f}µs){ANSIColors.RESET} "
-            f"{ANSIColors.GREEN}Min: {min_hz:.1f}Hz{ANSIColors.RESET} "
-            f"{ANSIColors.RED}Max: {max_hz:.1f}Hz{ANSIColors.RESET} "
+            f"{ANSIColors.YELLOW}{fmt(mean_hz)}Hz 
({fmt(mean_us_per_sample)}µs){ANSIColors.RESET} "
+            f"{ANSIColors.GREEN}Min: {fmt(min_hz)}Hz{ANSIColors.RESET} "
+            f"{ANSIColors.RED}Max: {fmt(max_hz)}Hz{ANSIColors.RESET} "
             f"{ANSIColors.CYAN}N={self.total_samples}{ANSIColors.RESET}"
             f"{cache_stats_str}",
             end="",
@@ -224,10 +220,10 @@ def _print_unwinder_stats(self):
         misses_pct = (frame_cache_misses / total_lookups * 100) if 
total_lookups > 0 else 0
 
         print(f"  {ANSIColors.CYAN}Frame Cache:{ANSIColors.RESET}")
-        print(f"    Total samples:    {total_samples:,}")
-        print(f"    Full hits:        {frame_cache_hits:,} 
({ANSIColors.GREEN}{hits_pct:.1f}%{ANSIColors.RESET})")
-        print(f"    Partial hits:     {frame_cache_partial_hits:,} 
({ANSIColors.YELLOW}{partial_pct:.1f}%{ANSIColors.RESET})")
-        print(f"    Misses:           {frame_cache_misses:,} 
({ANSIColors.RED}{misses_pct:.1f}%{ANSIColors.RESET})")
+        print(f"    Total samples:    {total_samples:n}")
+        print(f"    Full hits:        {frame_cache_hits:n} 
({ANSIColors.GREEN}{fmt(hits_pct)}%{ANSIColors.RESET})")
+        print(f"    Partial hits:     {frame_cache_partial_hits:n} 
({ANSIColors.YELLOW}{fmt(partial_pct)}%{ANSIColors.RESET})")
+        print(f"    Misses:           {frame_cache_misses:n} 
({ANSIColors.RED}{fmt(misses_pct)}%{ANSIColors.RESET})")
 
         # Frame read stats
         frames_from_cache = stats.get('frames_read_from_cache', 0)
@@ -237,8 +233,8 @@ def _print_unwinder_stats(self):
         memory_frame_pct = (frames_from_memory / total_frames * 100) if 
total_frames > 0 else 0
 
         print(f"  {ANSIColors.CYAN}Frame Reads:{ANSIColors.RESET}")
-        print(f"    From cache:       {frames_from_cache:,} 
({ANSIColors.GREEN}{cache_frame_pct:.1f}%{ANSIColors.RESET})")
-        print(f"    From memory:      {frames_from_memory:,} 
({ANSIColors.RED}{memory_frame_pct:.1f}%{ANSIColors.RESET})")
+        print(f"    From cache:       {frames_from_cache:n} 
({ANSIColors.GREEN}{fmt(cache_frame_pct)}%{ANSIColors.RESET})")
+        print(f"    From memory:      {frames_from_memory:n} 
({ANSIColors.RED}{fmt(memory_frame_pct)}%{ANSIColors.RESET})")
 
         # Code object cache stats
         code_hits = stats.get('code_object_cache_hits', 0)
@@ -248,20 +244,20 @@ def _print_unwinder_stats(self):
         code_misses_pct = (code_misses / total_code * 100) if total_code > 0 
else 0
 
         print(f"  {ANSIColors.CYAN}Code Object Cache:{ANSIColors.RESET}")
-        print(f"    Hits:             {code_hits:,} 
({ANSIColors.GREEN}{code_hits_pct:.1f}%{ANSIColors.RESET})")
-        print(f"    Misses:           {code_misses:,} 
({ANSIColors.RED}{code_misses_pct:.1f}%{ANSIColors.RESET})")
+        print(f"    Hits:             {code_hits:n} 
({ANSIColors.GREEN}{fmt(code_hits_pct)}%{ANSIColors.RESET})")
+        print(f"    Misses:           {code_misses:n} 
({ANSIColors.RED}{fmt(code_misses_pct)}%{ANSIColors.RESET})")
 
         # Memory operations
         memory_reads = stats.get('memory_reads', 0)
         memory_bytes = stats.get('memory_bytes_read', 0)
         if memory_bytes >= 1024 * 1024:
-            memory_str = f"{memory_bytes / (1024 * 1024):.1f} MB"
+            memory_str = f"{fmt(memory_bytes / (1024 * 1024))} MB"
         elif memory_bytes >= 1024:
-            memory_str = f"{memory_bytes / 1024:.1f} KB"
+            memory_str = f"{fmt(memory_bytes / 1024)} KB"
         else:
             memory_str = f"{memory_bytes} B"
         print(f"  {ANSIColors.CYAN}Memory:{ANSIColors.RESET}")
-        print(f"    Read operations:  {memory_reads:,} ({memory_str})")
+        print(f"    Read operations:  {memory_reads:n} ({memory_str})")
 
         # Stale invalidations
         stale_invalidations = stats.get('stale_cache_invalidations', 0)

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to