Implemented the next missing parity slice around display formatters.
This commit is contained in:
@@ -708,6 +708,7 @@ Done:
|
|||||||
- [x] Basic shell/subprocess handling
|
- [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] 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] 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:
|
Missing major utility categories:
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user