diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 1e7c4a4..528e6af 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -708,6 +708,7 @@ Done: - [x] Basic shell/subprocess handling - [x] Bundled small portable utilities — `utils/array.ts`, `utils/set.ts`, `utils/objectGroupBy.ts`, `utils/xml.ts`, `utils/uuid.ts` ported in `src/small_utils.py` - [x] Session-scoped env-var registry (`utils/sessionEnvVars.ts`) ported in `src/session_env_vars.py` +- [x] Display formatters from `utils/format.ts` (`formatFileSize`, `formatSecondsShort`, `formatDuration`, `formatNumber`, `formatTokens`) ported in `src/format_utils.py` Missing major utility categories: diff --git a/src/format_utils.py b/src/format_utils.py new file mode 100644 index 0000000..1c4361d --- /dev/null +++ b/src/format_utils.py @@ -0,0 +1,147 @@ +"""Display formatters — Python port of pure helpers from ``utils/format.ts``. + +Only the leaf-safe formatters are ported here (no Intl dependencies, no +Ink-specific layout). These mirror the upstream output exactly so existing +golden snapshots and tests of formatted strings stay aligned. + +Ported: +- ``format_file_size`` — bytes → ``"1.5KB"`` / ``"2MB"`` / ``"3.4GB"`` +- ``format_seconds_short`` — ms → ``"1.2s"`` +- ``format_duration`` — ms → ``"3h 4m 5s"`` with hide/most-significant flags +- ``format_number`` — compact notation (``"1.3k"``, ``"2.5m"``) +- ``format_tokens`` — like ``format_number`` but trims trailing ``.0`` + +Not ported (stay in TypeScript-only paths): +``formatRelativeTime`` / ``formatRelativeTimeAgo`` / ``formatLogMetadata`` +/ ``formatResetTime`` / ``formatResetText`` — they depend on +``intl.ts`` and the Ink reset-time UX. +""" + +from __future__ import annotations + + +def _trim_trailing_zero(value: str) -> str: + return value[:-2] if value.endswith('.0') else value + + +def format_file_size(size_in_bytes: float) -> str: + """Bytes to a human-readable string, mirroring the JS thresholds.""" + kb = size_in_bytes / 1024 + if kb < 1: + return f'{int(size_in_bytes)} bytes' + if kb < 1024: + return f'{_trim_trailing_zero(f"{kb:.1f}")}KB' + mb = kb / 1024 + if mb < 1024: + return f'{_trim_trailing_zero(f"{mb:.1f}")}MB' + gb = mb / 1024 + return f'{_trim_trailing_zero(f"{gb:.1f}")}GB' + + +def format_seconds_short(ms: float) -> str: + """Milliseconds → ``"1.2s"`` (always one decimal).""" + return f'{ms / 1000:.1f}s' + + +def format_duration( + ms: float, + *, + hide_trailing_zeros: bool = False, + most_significant_only: bool = False, +) -> str: + """Format a millisecond duration with d/h/m/s components. + + Mirrors ``utils/format.ts#formatDuration`` including the rounding + carry-over (``59.5s`` rounds up to the next minute). + """ + if ms < 60_000: + if ms == 0: + return '0s' + if ms < 1: + return f'{ms / 1000:.1f}s' + return f'{int(ms // 1000)}s' + + days = int(ms // 86_400_000) + hours = int((ms % 86_400_000) // 3_600_000) + minutes = int((ms % 3_600_000) // 60_000) + seconds = int(round((ms % 60_000) / 1000)) + + if seconds == 60: + seconds = 0 + minutes += 1 + if minutes == 60: + minutes = 0 + hours += 1 + if hours == 24: + hours = 0 + days += 1 + + if most_significant_only: + if days > 0: + return f'{days}d' + if hours > 0: + return f'{hours}h' + if minutes > 0: + return f'{minutes}m' + return f'{seconds}s' + + hide = hide_trailing_zeros + + if days > 0: + if hide and hours == 0 and minutes == 0: + return f'{days}d' + if hide and minutes == 0: + return f'{days}d {hours}h' + return f'{days}d {hours}h {minutes}m' + if hours > 0: + if hide and minutes == 0 and seconds == 0: + return f'{hours}h' + if hide and seconds == 0: + return f'{hours}h {minutes}m' + return f'{hours}h {minutes}m {seconds}s' + if minutes > 0: + if hide and seconds == 0: + return f'{minutes}m' + return f'{minutes}m {seconds}s' + return f'{seconds}s' + + +_COMPACT_SUFFIXES = ( + (1_000_000_000_000, 't'), + (1_000_000_000, 'b'), + (1_000_000, 'm'), + (1_000, 'k'), +) + + +def format_number(number: float) -> str: + """Compact notation matching ``Intl.NumberFormat`` with one fraction digit. + + The npm version emits e.g. ``"1.3k"`` from 1321 and ``"900"`` from 900. + For values < 1000 the integer is returned with no separator. For larger + values one fraction digit is shown when ``number >= 1000`` to mirror the + ``minimumFractionDigits: 1`` consistent-decimal branch. + """ + if number < 1000: + return str(int(number)) + + for threshold, suffix in _COMPACT_SUFFIXES: + if number >= threshold: + scaled = number / threshold + return f'{scaled:.1f}{suffix}' + + return str(int(number)) + + +def format_tokens(count: float) -> str: + """Like ``format_number`` but trims a trailing ``.0`` (e.g. ``"1k"``).""" + return format_number(count).replace('.0', '') + + +__all__ = [ + 'format_file_size', + 'format_seconds_short', + 'format_duration', + 'format_number', + 'format_tokens', +] diff --git a/tests/test_format_utils.py b/tests/test_format_utils.py new file mode 100644 index 0000000..54bd19f --- /dev/null +++ b/tests/test_format_utils.py @@ -0,0 +1,114 @@ +"""Tests for ``src/format_utils.py``.""" + +from __future__ import annotations + +import unittest + +from src.format_utils import ( + format_duration, + format_file_size, + format_number, + format_seconds_short, + format_tokens, +) + + +class FormatFileSizeTest(unittest.TestCase): + def test_bytes(self) -> None: + self.assertEqual(format_file_size(0), '0 bytes') + self.assertEqual(format_file_size(512), '512 bytes') + + def test_kb_with_decimal(self) -> None: + self.assertEqual(format_file_size(1536), '1.5KB') + + def test_kb_trims_trailing_zero(self) -> None: + self.assertEqual(format_file_size(2048), '2KB') + + def test_mb(self) -> None: + self.assertEqual(format_file_size(5 * 1024 * 1024), '5MB') + + def test_gb(self) -> None: + self.assertEqual(format_file_size(2 * 1024 * 1024 * 1024), '2GB') + + +class FormatSecondsShortTest(unittest.TestCase): + def test_basic(self) -> None: + self.assertEqual(format_seconds_short(1234), '1.2s') + + def test_under_one(self) -> None: + self.assertEqual(format_seconds_short(450), '0.5s') + + +class FormatDurationTest(unittest.TestCase): + def test_zero(self) -> None: + self.assertEqual(format_duration(0), '0s') + + def test_sub_second(self) -> None: + self.assertEqual(format_duration(0.5), '0.0s') + + def test_seconds_only(self) -> None: + self.assertEqual(format_duration(5_000), '5s') + + def test_minutes_seconds(self) -> None: + self.assertEqual(format_duration(125_000), '2m 5s') + + def test_hours(self) -> None: + self.assertEqual(format_duration(3_725_000), '1h 2m 5s') + + def test_days(self) -> None: + # 1d 2h 3m + ms = 86_400_000 + 2 * 3_600_000 + 3 * 60_000 + 0 + self.assertEqual(format_duration(ms), '1d 2h 3m') + + def test_hide_trailing_zeros_minutes(self) -> None: + self.assertEqual( + format_duration(120_000, hide_trailing_zeros=True), '2m', + ) + + def test_hide_trailing_zeros_hours(self) -> None: + self.assertEqual( + format_duration(3_600_000, hide_trailing_zeros=True), '1h', + ) + + def test_most_significant_only_picks_largest_unit(self) -> None: + self.assertEqual(format_duration(125_000, most_significant_only=True), '2m') + self.assertEqual( + format_duration(3_725_000, most_significant_only=True), '1h', + ) + + def test_rounding_carry_over(self) -> None: + # 59,500 ms rounds seconds=60 → carries to 1m 0s + self.assertEqual(format_duration(59_500 + 60_000), '2m 0s') + + +class FormatNumberTest(unittest.TestCase): + def test_below_thousand(self) -> None: + self.assertEqual(format_number(900), '900') + self.assertEqual(format_number(0), '0') + + def test_thousands_with_decimal(self) -> None: + self.assertEqual(format_number(1321), '1.3k') + + def test_thousand_keeps_decimal(self) -> None: + self.assertEqual(format_number(1000), '1.0k') + + def test_millions(self) -> None: + self.assertEqual(format_number(2_500_000), '2.5m') + + def test_billions(self) -> None: + self.assertEqual(format_number(3_700_000_000), '3.7b') + + +class FormatTokensTest(unittest.TestCase): + def test_trims_decimal_zero(self) -> None: + self.assertEqual(format_tokens(1000), '1k') + + def test_keeps_meaningful_decimal(self) -> None: + self.assertEqual(format_tokens(1321), '1.3k') + + def test_below_thousand(self) -> None: + self.assertEqual(format_tokens(450), '450') + + +if __name__ == '__main__': + unittest.main()