Coverage for src / sql_tool / cli / helpers.py: 100%

81 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-14 15:28 -0500

1"""Shared CLI formatting helpers.""" 

2 

3from __future__ import annotations 

4 

5import re 

6 

7 

8def normalize_pg_interval(pg_interval: str | None) -> str: 

9 """Convert PostgreSQL interval strings to human-readable form. 

10 

11 Examples: "01:00:00" → "1 hour", "00:10:00" → "10 minutes", 

12 "24:00:00" → "1 day", "1 mon" → "1 month". 

13 Already-readable strings like "7 days" pass through unchanged. 

14 """ 

15 if not pg_interval: 

16 return "-" 

17 

18 val = pg_interval.strip() 

19 

20 # Handle HH:MM:SS format 

21 match = re.match(r"^(\d+):(\d{2}):(\d{2})$", val) 

22 if match: 

23 hours, minutes, seconds = ( 

24 int(match.group(1)), 

25 int(match.group(2)), 

26 int(match.group(3)), 

27 ) 

28 total_seconds = hours * 3600 + minutes * 60 + seconds 

29 if total_seconds == 0: 

30 return "0 seconds" 

31 parts = [] 

32 days, remainder = divmod(total_seconds, 86400) 

33 if days: 

34 parts.append(f"{days} day{'s' if days != 1 else ''}") 

35 hrs, remainder = divmod(remainder, 3600) 

36 if hrs: 

37 parts.append(f"{hrs} hour{'s' if hrs != 1 else ''}") 

38 mins, secs = divmod(remainder, 60) 

39 if mins: 

40 parts.append(f"{mins} minute{'s' if mins != 1 else ''}") 

41 if secs: 

42 parts.append(f"{secs} second{'s' if secs != 1 else ''}") 

43 return " ".join(parts) 

44 

45 # Normalize "mon" → "month" 

46 val = re.sub(r"\bmon\b", "month", val) 

47 val = re.sub(r"\bmons\b", "months", val) 

48 

49 return val 

50 

51 

52def format_duration_human(seconds: float | None) -> str: 

53 if seconds is None: 

54 return "" 

55 

56 if seconds < 60: 

57 return f"{int(seconds)}s" 

58 elif seconds < 3600: 

59 minutes = int(seconds / 60) 

60 return f"{minutes}m" 

61 elif seconds < 86400: 

62 hours = int(seconds / 3600) 

63 return f"{hours}h" 

64 else: 

65 days = int(seconds / 86400) 

66 return f"{days}d" 

67 

68 

69def format_relative_time(seconds: float | None) -> str: 

70 if seconds is None: 

71 return "" 

72 return f"{format_duration_human(seconds)} ago" 

73 

74 

75def fmt_size(b: int | None) -> str: 

76 """Format bytes as human-readable size for table output.""" 

77 if not b: 

78 return "-" 

79 units = [("TB", 1 << 40), ("GB", 1 << 30), ("MB", 1 << 20), ("KB", 1 << 10)] 

80 for suffix, threshold in units: 

81 if b >= threshold: 

82 value = b / threshold 

83 return f"{value:.0f} {suffix}" if value >= 10 else f"{value:.1f} {suffix}" 

84 return f"{b}B" 

85 

86 

87def format_size_compact(size_bytes: int | None) -> str: 

88 """Format bytes as compact human-readable: 2KB, 300M, 1.0GB, 3TB.""" 

89 if not size_bytes: 

90 return "0B" 

91 units = [("TB", 1 << 40), ("GB", 1 << 30), ("M", 1 << 20), ("KB", 1 << 10)] 

92 for suffix, threshold in units: 

93 if size_bytes >= threshold: 

94 value = size_bytes / threshold 

95 if value >= 10: 

96 return f"{value:.0f}{suffix}" 

97 return f"{value:.1f}{suffix}" 

98 return f"{size_bytes}B" 

99 

100 

101def format_size_gb(size_bytes: int | None) -> str: 

102 """Format bytes with adaptive units for CSV/JSON output.""" 

103 if not size_bytes: 

104 return "0 bytes" 

105 units = [("TB", 1 << 40), ("GB", 1 << 30), ("MB", 1 << 20), ("KB", 1 << 10)] 

106 for suffix, threshold in units: 

107 if size_bytes >= threshold: 

108 return f"{size_bytes / threshold:.2f} {suffix}" 

109 return f"{size_bytes} bytes" 

110 

111 

112def format_timestamp(ts: str | None) -> str: 

113 """Strip microseconds and timezone from PostgreSQL timestamps.""" 

114 if not ts or ts == "-infinity" or ts == "infinity": 

115 return "-" 

116 # Strip microseconds and timezone for readability 

117 if "." in ts: 

118 ts = ts.split(".")[0] 

119 elif "+" in ts: 

120 ts = ts.split("+")[0] 

121 return ts