From 662723a73ee9cd91f9cde2e1dbdd5a049734d9b1 Mon Sep 17 00:00:00 2001 From: delameter Date: Tue, 17 May 2022 03:45:10 +0300 Subject: [PATCH 1/4] - `fmt_prefixed_unit` extended for working with decimal and binary metric prefixes - `fmt_time_delta` extended with new settings - value rounding transferred from `fmt_auto_float` to `fmt_prefixed_unit` --- pytermor/__init__.py | 7 +- pytermor/util/__init__.py | 8 +- pytermor/util/auto_float.py | 30 +-- pytermor/util/fmtd.py | 24 +- pytermor/util/prefixed_unit.py | 125 ++++++---- pytermor/util/{filter.py => string_filter.py} | 0 pytermor/util/time_delta.py | 135 ++++++----- tests/test_fmt.py | 6 +- tests/test_registry.py | 2 +- tests/test_seq.py | 6 +- tests/test_util.py | 229 ++++++++++++++++++ tests/util/test_util.py | 222 ----------------- 12 files changed, 422 insertions(+), 372 deletions(-) rename pytermor/util/{filter.py => string_filter.py} (100%) create mode 100644 tests/test_util.py delete mode 100644 tests/util/test_util.py diff --git a/pytermor/__init__.py b/pytermor/__init__.py index e095b1d3..a37cdf2a 100644 --- a/pytermor/__init__.py +++ b/pytermor/__init__.py @@ -25,10 +25,11 @@ 'rjust_fmtd', 'center_fmtd', + 'fmt_auto_float', 'fmt_prefixed_unit', 'fmt_time_delta', - 'fmt_auto_float', - 'PrefixedUnitFmtPreset', - 'TimeDeltaFmtPreset', + 'PrefixedUnitPreset', + 'SiPrefixedUnitPreset', + 'TimeDeltaPreset', ] __version__ = '1.7.4' diff --git a/pytermor/util/__init__.py b/pytermor/util/__init__.py index b3515a03..948b7bb8 100644 --- a/pytermor/util/__init__.py +++ b/pytermor/util/__init__.py @@ -2,7 +2,7 @@ # pytermor [ANSI formatted terminal output toolset] # (C) 2022 A. Shavykin <0.delameter@gmail.com> # ----------------------------------------------------------------------------- -from .filter import * +from .string_filter import * from .fmtd import * from .auto_float import * @@ -21,8 +21,10 @@ 'center_fmtd', 'fmt_prefixed_unit', + 'PrefixedUnitPreset', + 'FMT_PRESET_SI_METRIC', + 'FMT_PRESET_SI_BINARY', 'fmt_time_delta', + 'TimeDeltaPreset', 'fmt_auto_float', - 'PrefixedUnitFmtPreset', - 'TimeDeltaFmtPreset', ] diff --git a/pytermor/util/auto_float.py b/pytermor/util/auto_float.py index e5586a96..085c43fa 100644 --- a/pytermor/util/auto_float.py +++ b/pytermor/util/auto_float.py @@ -5,34 +5,28 @@ from math import trunc -def fmt_auto_float(value: float, max_len: int, output_int: bool) -> str: +def fmt_auto_float(value: float, max_len: int) -> str: """ Dynamically adjust decimal digits amount to fill up output string with significant digits as much as possible. Examples: - - auto_float(1234.56, 4, False) -> 1235 - - auto_float(123.56, 4, False) -> 124 - - auto_float(12.56, 4, False) -> 12.6 - - auto_float(1.56, 4, False) -> 1.56 - - auto_float(1234.56, 4, True) -> 1235 - - auto_float(12.56, 4, True) -> 13 + - auto_float(1234.56, 4) -> 1235 + - auto_float( 123.56, 4) -> 124 + - auto_float( 12.56, 4) -> 12.6 + - auto_float( 1.56, 4) -> 1.56 :param value: value to format :param max_len: maximum output string length (total) - :param output_int: omit decimal point and everything to the right of it :return: formatted value """ - if not output_int: - max_decimals_len = 2 - integer_len = len(str(trunc(value))) - decimals_and_point_len = min(max_decimals_len + 1, max_len - integer_len) + max_decimals_len = 2 + integer_len = len(str(trunc(value))) + decimals_and_point_len = min(max_decimals_len + 1, max_len - integer_len) - decimals_len = 0 - if decimals_and_point_len >= 2: # dot without decimals makes no sense - decimals_len = decimals_and_point_len - 1 - dot_str = f'.{decimals_len!s}' - else: - dot_str = '.0' + decimals_len = 0 + if decimals_and_point_len >= 2: # dot without decimals makes no sense + decimals_len = decimals_and_point_len - 1 + dot_str = f'.{decimals_len!s}' return f'{value:{max_len}{dot_str}f}' diff --git a/pytermor/util/fmtd.py b/pytermor/util/fmtd.py index 14dfc9fc..c9392a47 100644 --- a/pytermor/util/fmtd.py +++ b/pytermor/util/fmtd.py @@ -7,14 +7,10 @@ def ljust_fmtd(s: str, width: int, fillchar: str = ' ') -> str: """ - Correctly justifies input that can include SGR sequences. Apart from - that is very similar to regular str.ljust(). - - :param s: string to extend - :param width: target string length - :param fillchar: append this char to target - :return: **s** padded to the left side with *fillchars* so that now it's - length corresponds to *width* + SGR-formatting-aware implementation of str.ljust(). + + Return a left-justified string of length width. Padding is done + using the specified fill character (default is a space). """ sanitized = ReplaceSGR().apply(s) return s + fillchar * max(0, width - len(sanitized)) @@ -22,7 +18,10 @@ def ljust_fmtd(s: str, width: int, fillchar: str = ' ') -> str: def rjust_fmtd(s: str, width: int, fillchar: str = ' ') -> str: """ - SGR-aware implementation of str.rjust(). @see: ljust_fmtd + SGR-formatting-aware implementation of str.rjust(). + + Return a right-justified string of length width. Padding is done + using the specified fill character (default is a space). """ sanitized = ReplaceSGR().apply(s) return fillchar * max(0, width - len(sanitized)) + s @@ -30,11 +29,10 @@ def rjust_fmtd(s: str, width: int, fillchar: str = ' ') -> str: def center_fmtd(s: str, width: int, fillchar: str = ' ') -> str: """ - SGR-aware implementation of str.rjust(). + SGR-formatting-aware implementation of str.center(). - .. seealso:: ljust_fmtd - .. note:: blabla - .. todo:: blabla + Return a centered string of length width. Padding is done using the + specified fill character (default is a space). """ sanitized = ReplaceSGR().apply(s) fill_len = max(0, width - len(sanitized)) diff --git a/pytermor/util/prefixed_unit.py b/pytermor/util/prefixed_unit.py index 1fd1cfd9..e1c2b757 100644 --- a/pytermor/util/prefixed_unit.py +++ b/pytermor/util/prefixed_unit.py @@ -5,37 +5,55 @@ from __future__ import annotations from dataclasses import dataclass +from math import trunc from typing import List from . import fmt_auto_float @dataclass -class PrefixedUnitFmtPreset: - """ - Default settings are suitable for formatting sizes in bytes ( - *mcoef* =1024, prefixes are k, M, G, T etc.) - - *max_value_len* cannot effectively be less than 3, at least - as long as *prefix_coef* =1024, because there is no way for - method to insert into output more digits than it can shift - back using multiplier (/divider) coefficient and prefixed units. - """ - max_value_len: int = 5 - expand_to_max: bool = False - mcoef: float = 1024.0 - unit: str|None = 'b' - unit_separator: str|None = ' ' - prefixes: List[str] = None - - -FMT_PRESET_DEFAULT_KEY = 8 -FMT_PRESETS = {FMT_PRESET_DEFAULT_KEY: PrefixedUnitFmtPreset()} - -FMT_PREFIXES_DEFAULT = ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z'] - - -def fmt_prefixed_unit(value: int, preset: PrefixedUnitFmtPreset = None) -> str: +class PrefixedUnitPreset: + max_value_len: int + integer_input: bool + unit: str|None + unit_separator: str|None + mcoef: float + prefixes: List[str|None]|None + prefix_zero_idx: int|None + + +PREFIXES_SI = ['y', 'z', 'a', 'f', 'p', 'n', 'μ', 'm', None, 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'] +PREFIX_ZERO_SI = 8 + +""" +Suitable for formatting any SI unit with values +from approximately 10^-27 to 10^27 . + +*max_value_len* must be at least `4`, because it's a +minimum requirement for displaying values from 999 to -999. +Next number to 999 is 1000, which will be displayed as 1k. +""" +FMT_PRESET_SI_METRIC = PrefixedUnitPreset( + max_value_len=5, + integer_input=False, + unit='V', + unit_separator=' ', + mcoef=1000.0, + prefixes=PREFIXES_SI, + prefix_zero_idx=PREFIX_ZERO_SI, +) +FMT_PRESET_SI_BINARY = PrefixedUnitPreset( + max_value_len=5, + integer_input=True, + unit='b', + unit_separator=' ', + mcoef=1024.0, + prefixes=PREFIXES_SI, + prefix_zero_idx=PREFIX_ZERO_SI, +) + + +def fmt_prefixed_unit(value: float, preset: PrefixedUnitPreset = None) -> str: """ Format *value* using *preset* settings. The main idea of this method is to fit into specified string length as much significant digits as it's @@ -43,33 +61,36 @@ def fmt_prefixed_unit(value: int, preset: PrefixedUnitFmtPreset = None) -> str: indicate power. :param value: input value - :param preset: formatter settings + :param preset: formatter settings, default is SiPrefixedUnitPreset with base 1024 :return: formatted value """ if preset is None: - preset = FMT_PRESETS[FMT_PRESET_DEFAULT_KEY] - value = max(0, value) - - def iterator(_value: float) -> str: - prefixes = preset.prefixes if preset.prefixes else FMT_PREFIXES_DEFAULT - for unit_idx, unit_prefix in enumerate(prefixes): - unit = preset.unit if preset.unit else "" - unit_separator = preset.unit_separator if preset.unit_separator else "" - unit_full = f'{unit_prefix}{unit}' - - if _value >= preset.mcoef: - _value /= preset.mcoef - continue - - num_str = fmt_auto_float(_value, preset.max_value_len, (unit_idx == 0)) - return f'{num_str}{unit_separator}{unit_full}' - - # no more prefixes left - return f'{_value!r:{preset.max_value_len}.{preset.max_value_len}}{preset.unit_separator or ""}' + \ - '?' * max([len(p) for p in prefixes]) + \ - (preset.unit or "") - - result = iterator(value) - if not preset.expand_to_max: - result = result.strip() - return result + preset = FMT_PRESET_SI_BINARY + + prefixes = preset.prefixes or [''] + unit_separator = preset.unit_separator or '' + unit_idx = preset.prefix_zero_idx or '' + + while 0 <= unit_idx < len(prefixes): + if 0.0 < abs(value) <= 1/preset.mcoef: + value *= preset.mcoef + unit_idx -= 1 + continue + elif abs(value) >= preset.mcoef: + value /= preset.mcoef + unit_idx += 1 + continue + + unit_full = f'{prefixes[unit_idx] or ""}{preset.unit or ""}' + + if preset.integer_input and unit_idx == preset.prefix_zero_idx: + num_str = f'{trunc(value)!s:.{preset.max_value_len}s}' + else: + num_str = fmt_auto_float(value, preset.max_value_len) + + return f'{num_str.strip()}{unit_separator}{unit_full}' + + # no more prefixes left + return f'{value!r:{preset.max_value_len}.{preset.max_value_len}}{preset.unit_separator or ""}' + \ + '?' * max([len(p) for p in prefixes]) + \ + (preset.unit or "") diff --git a/pytermor/util/filter.py b/pytermor/util/string_filter.py similarity index 100% rename from pytermor/util/filter.py rename to pytermor/util/string_filter.py diff --git a/pytermor/util/time_delta.py b/pytermor/util/time_delta.py index d5774145..5a910bc4 100644 --- a/pytermor/util/time_delta.py +++ b/pytermor/util/time_delta.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import dataclass -from math import floor +from math import floor, trunc, isclose from typing import List @@ -19,50 +19,66 @@ class TimeUnit: @dataclass -class TimeDeltaFmtPreset: - units: List[TimeUnit] = List[TimeUnit] - allow_negative: bool = True - unit_separator: str|None = ' ' - plural_suffix: str|None = 's' - overflow_msg: str|None = 'OVERFLOW' +class TimeDeltaPreset: + units: List[TimeUnit] + allow_negative: bool + unit_separator: str|None + plural_suffix: str|None + overflow_msg: str|None FMT_PRESET_DEFAULT_KEY = 10 + FMT_PRESETS = { - 3: TimeDeltaFmtPreset([ + 3: TimeDeltaPreset([ TimeUnit('s', 60), TimeUnit('m', 60), TimeUnit('h', 24), TimeUnit('d', overflow_afer=99), - ], allow_negative=False, unit_separator=None, - plural_suffix=None, overflow_msg='ERR'), + ], allow_negative=False, + unit_separator=None, + plural_suffix=None, + overflow_msg='ERR', + ), - 4: TimeDeltaFmtPreset([ + 4: TimeDeltaPreset([ TimeUnit('s', 60), TimeUnit('m', 60), TimeUnit('h', 24), TimeUnit('d', 30), TimeUnit('M', 12), TimeUnit('y', overflow_afer=99), - ], allow_negative=False, plural_suffix=None, overflow_msg='ERRO'), + ], allow_negative=False, + unit_separator=' ', + plural_suffix=None, + overflow_msg='ERRO', + ), - 6: TimeDeltaFmtPreset([ + 6: TimeDeltaPreset([ TimeUnit('sec', 60), TimeUnit('min', 60), TimeUnit('hr', 24, collapsible_after=10), TimeUnit('day', 30, collapsible_after=10), TimeUnit('mon', 12), TimeUnit('yr', overflow_afer=99), - ], allow_negative=False, plural_suffix=None), + ], allow_negative=False, + unit_separator=' ', + plural_suffix=None, + overflow_msg='OVERFL', + ), - FMT_PRESET_DEFAULT_KEY: TimeDeltaFmtPreset([ + FMT_PRESET_DEFAULT_KEY: TimeDeltaPreset([ TimeUnit('sec', 60), TimeUnit('min', 60, custom_short='min'), TimeUnit('hour', 24, collapsible_after=24), TimeUnit('day', 30, collapsible_after=10), TimeUnit('month', 12), TimeUnit('year', overflow_afer=999), - ]) + ], allow_negative=True, + unit_separator=' ', + plural_suffix='s', + overflow_msg='OVERFLOW', + ), } @@ -94,7 +110,8 @@ def fmt_time_delta(seconds: float, max_len: int = None) -> str: else: fmt_preset_list = sorted( [key for key in FMT_PRESETS.keys() if key <= max_len], - key=lambda k: k, reverse=True + key=lambda k: k, + reverse=True, ) if len(fmt_preset_list) > 0: preset = FMT_PRESETS[fmt_preset_list[0]] @@ -102,41 +119,51 @@ def fmt_time_delta(seconds: float, max_len: int = None) -> str: if not preset: raise ValueError(f'No settings defined for max length = {max_len} (or less)') - def iterator(abs_seconds: float) -> str|None: - num = abs_seconds - unit_idx = 0 - prev_frac = '' - - while unit_idx < len(preset.units): - unit = preset.units[unit_idx] - if unit.overflow_afer and num > unit.overflow_afer: - return preset.overflow_msg[0:max_len] - - unit_name = unit.name - unit_name_suffixed = unit_name - if preset.plural_suffix and num > 1: - unit_name_suffixed += preset.plural_suffix - - short_unit_name = unit_name[0] - if unit.custom_short: - short_unit_name = unit.custom_short - - next_unit_ratio = unit.in_next - unit_separator = preset.unit_separator or '' - - if num < 1: - return f'0{unit_separator}{unit_name_suffixed:3s}' - elif unit.collapsible_after is not None and num < unit.collapsible_after: - return f'{num:1.0f}{short_unit_name:1s}{unit_separator}{prev_frac:<3s}' - elif not next_unit_ratio or num < next_unit_ratio: - return f'{num:>2.0f}{unit_separator}{unit_name_suffixed:<3s}' + num = abs(seconds) + unit_idx = 0 + prev_frac = '' + + negative = preset.allow_negative and seconds < 0 + sign = '-' if negative else '' + result = None + + while result is None and unit_idx < len(preset.units): + unit = preset.units[unit_idx] + if unit.overflow_afer and num > unit.overflow_afer: + result = preset.overflow_msg[0:max_len] + break + + unit_name = unit.name + unit_name_suffixed = unit_name + if preset.plural_suffix and trunc(num) != 1: + unit_name_suffixed += preset.plural_suffix + + short_unit_name = unit_name[0] + if unit.custom_short: + short_unit_name = unit.custom_short + + next_unit_ratio = unit.in_next + unit_separator = preset.unit_separator or '' + + if abs(num) < 1: + if negative: + result = f'~0{unit_separator}{unit_name_suffixed:s}' + elif isclose(num, 0, abs_tol=1e-03): + result = f'0{unit_separator}{unit_name_suffixed:s}' else: - next_num = floor(num / next_unit_ratio) - prev_frac = '{:d}{:1s}'.format(floor(num - (next_num * next_unit_ratio)), short_unit_name) - num = next_num - unit_idx += 1 - continue - - sign = '-' if preset.allow_negative and seconds < 0 else '' - result = iterator(abs(seconds)) - return sign + result.strip() if result else '' + result = f'<1{unit_separator}{unit_name:s}' + + elif unit.collapsible_after is not None and num < unit.collapsible_after: + result = f'{sign}{floor(num):d}{short_unit_name:s}{unit_separator}{prev_frac: +# ----------------------------------------------------------------------------- +import sys +import unittest +from datetime import timedelta + +from pytermor import fmt, fmt_time_delta, fmt_prefixed_unit, fmt_auto_float, PrefixedUnitPreset, \ + FMT_PRESET_SI_METRIC, FMT_PRESET_SI_BINARY + + +def is_verbose_mode() -> bool: + return any([opt in sys.argv for opt in ['-v', '--verbose']]) + + +def print_verbose(value='', **argv): + if not is_verbose_mode(): + return + print(fmt.cyan(value or ""), flush=True, **argv) + + +class TestStringFilter(unittest.TestCase): + pass # @TODO + + +class TestFormatAwareAlignment(unittest.TestCase): + pass # @TODO + + +class TestPrefixedUnit(unittest.TestCase): + expected_format_dataset = [ + ['-142 Tb', -156530231500223], + ['-13.5 Gb', -14530231500], + ['-2.33 Gb', -2501234567], + ['-668 Mb', -700500000], + ['-41.1 Mb', -43106100], + ['-1.20 Mb', -1257800], + ['-130 kb', -133300], + ['-44.1 kb', -45200], + ['-14.6 kb', -15000], + ['-6.08 kb', -6230], + ['-1.05 kb', -1080], + ['-1.00 kb', -1024], + ['-1010 b', -1010], + ['-631 b', -631], + ['-180 b', -180], + ['-43 b', -43], + ['-10 b', -10], + ['-1 b', -1], + ['0 b', 0], + ['1 b', 1], + ['10 b', 10], + ['43 b', 43], + ['180 b', 180], + ['631 b', 631], + ['1010 b', 1010], + ['1.00 kb', 1024], + ['1.05 kb', 1080], + ['6.08 kb', 6230], + ['14.65 kb', 15000], + ['44.14 kb', 45200], + ['130.2 kb', 133300], + ['1.20 Mb', 1257800], + ['41.11 Mb', 43106100], + ['668.0 Mb', 700500000], + ['2.33 Gb', 2501234567], + ['13.53 Gb', 14530231500], + ['142.4 Tb', 156530231500223], + ] + + def test_output_has_expected_format(self): + for idx, (expected_output, input_num) in enumerate(self.expected_format_dataset): + with self.subTest(msg=f'Testing #{idx} {input_num} -> "{expected_output}"'): + actual_output = fmt_prefixed_unit(input_num) + print_verbose(actual_output, end=', ') + self.assertEqual(expected_output, actual_output) + + req_len_dataset = [ + [8, FMT_PRESET_SI_METRIC], + [8, FMT_PRESET_SI_BINARY], + [5, PrefixedUnitPreset( + max_value_len=3, integer_input=True, mcoef=1000.0, + prefixes=FMT_PRESET_SI_METRIC.prefixes, + prefix_zero_idx=FMT_PRESET_SI_METRIC.prefix_zero_idx, + unit=None, unit_separator=None, + )], + [6, PrefixedUnitPreset( + max_value_len=4, integer_input=True, mcoef=1000.0, + prefixes=FMT_PRESET_SI_METRIC.prefixes, + prefix_zero_idx=FMT_PRESET_SI_METRIC.prefix_zero_idx, + unit=None, unit_separator=None, + )], + ] + req_len_input_num_list = [.076 * pow(11, x) * (-1 * (x % 2)) for x in range(-20, 20)] + + def test_output_fits_in_required_length(self): + for preset_idx, (expected_max_len, preset) in enumerate(self.req_len_dataset): + print_verbose(f'\nLEN={expected_max_len:d}', end=': ') + for input_idx, input_num in enumerate(self.req_len_input_num_list): + with self.subTest(msg=f'Testing P{preset_idx} #{input_idx} "{input_num}" -> max length {expected_max_len}'): + actual_output = fmt_prefixed_unit(input_num, preset) + print_verbose(actual_output, end=', ') + self.assertGreaterEqual(expected_max_len, + len(actual_output), + f'Actual output ("{actual_output}") exceeds maximum') + + +class TestTimeDelta(unittest.TestCase): + expected_format_max_len = 10 + expected_format_dataset = [ + ['OVERFLOW', timedelta(days=-700000)], + ['-2 years', timedelta(days=-1000)], + ['-10 months', timedelta(days=-300)], + ['-3 months', timedelta(days=-100)], + ['-9d 23h', timedelta(days=-9, hours=-23)], + ['-5d 0h', timedelta(days=-5)], + ['-13h 30min', timedelta(days=-1, hours=10, minutes=30)], + ['-45 mins', timedelta(hours=-1, minutes=15)], + ['-5 mins', timedelta(minutes=-5)], + ['-2 secs', timedelta(seconds=-2.01)], + ['-2 secs', timedelta(seconds=-2)], + ['-1 sec', timedelta(seconds=-2, microseconds=1)], + ['-1 sec', timedelta(seconds=-1.9)], + ['-1 sec', timedelta(seconds=-1.1)], + ['-1 sec', timedelta(seconds=-1.0)], + ['~0 secs', timedelta(seconds=-0.5)], + ['~0 secs', timedelta(milliseconds=-50)], + ['~0 secs', timedelta(microseconds=-100)], + ['~0 secs', timedelta(microseconds=-1)], + ['0 secs', timedelta()], + ['0 secs', timedelta(microseconds=500)], + ['<1 sec', timedelta(milliseconds=25)], + ['<1 sec', timedelta(seconds=0.1)], + ['<1 sec', timedelta(seconds=0.9)], + ['1 sec', timedelta(seconds=1)], + ['1 sec', timedelta(seconds=1.0)], + ['1 sec', timedelta(seconds=1.1)], + ['1 sec', timedelta(seconds=1.9)], + ['1 sec', timedelta(seconds=2, microseconds=-1)], + ['2 secs', timedelta(seconds=2)], + ['2 secs', timedelta(seconds=2.0)], + ['2 secs', timedelta(seconds=2.5)], + ['10 secs', timedelta(seconds=10)], + ['1 min', timedelta(minutes=1)], + ['5 mins', timedelta(minutes=5)], + ['15 mins', timedelta(minutes=15)], + ['45 mins', timedelta(minutes=45)], + ['1h 30min', timedelta(hours=1, minutes=30)], + ['4h 15min', timedelta(hours=4, minutes=15)], + ['8h 59min', timedelta(hours=8, minutes=59, seconds=59)], + ['12h 30min', timedelta(hours=12, minutes=30)], + ['18h 45min', timedelta(hours=18, minutes=45)], + ['23h 50min', timedelta(hours=23, minutes=50)], + ['1d 0h', timedelta(days=1)], + ['3d 4h', timedelta(days=3, hours=4)], + ['5d 22h', timedelta(days=5, hours=22, minutes=51)], + ['6d 23h', timedelta(days=7, minutes=-1)], + ['9d 0h', timedelta(days=9)], + ['12 days', timedelta(days=12, hours=18)], + ['16 days', timedelta(days=16, hours=2)], + ['1 month', timedelta(days=30)], + ['1 month', timedelta(days=55)], + ['2 months', timedelta(days=70)], + ['2 months', timedelta(days=80)], + ['6 months', timedelta(days=200)], + ['11 months', timedelta(days=350)], + ['1 year', timedelta(days=390)], + ['2 years', timedelta(days=810)], + ['27 years', timedelta(days=10000)], + ['277 years', timedelta(days=100000)], + ['OVERFLOW', timedelta(days=400000)], + ] + + def test_output_has_expected_format(self): + for idx, (expected_output, input_arg) in enumerate(self.expected_format_dataset): + with self.subTest(msg=f'Test #{idx}: "{input_arg}" -> "{expected_output}"'): + if type(input_arg) is not timedelta: + input_arg = timedelta(days=input_arg) + + actual_output = fmt_time_delta(input_arg.total_seconds(), self.expected_format_max_len) + print_verbose(actual_output, end=', ') + self.assertEqual(expected_output, actual_output) + + req_len_expected_len_list = [3, 4, 6, 10, 9, 1000] + req_len_input_delta_list = [el[1] for el in expected_format_dataset] + + def test_output_fits_in_required_length(self): + for idx, expected_max_len in enumerate(self.req_len_expected_len_list): + print_verbose(f'\nLEN={expected_max_len:d}', end=': ') + for input_td in self.req_len_input_delta_list: + with self.subTest(msg=f'Testing #{idx} "{input_td}" -> max length {expected_max_len}'): + actual_output = fmt_time_delta(input_td.total_seconds(), expected_max_len) + print_verbose(actual_output, end=', ') + self.assertGreaterEqual(expected_max_len, len(actual_output)) + + invalid_len_list = [-5, 0, 1, 2] + + def test_invalid_max_length_fails(self): + for invalid_max_len in self.invalid_len_list: + with self.subTest(msg=f'Testing invalid max length {invalid_max_len}'): + self.assertRaises(ValueError, lambda: fmt_time_delta(100, invalid_max_len)) + + +class TestAutoFloat(unittest.TestCase): + expected_format_dataset = [ + ['300.0', [300, 5]], + ['30.00', [30, 5]], + [' 3.00', [3, 5]], + [' 0.30', [.3, 5]], + [' 0.03', [.03, 5]], + [' 0.00', [.003, 5]], + ['-5.00', [-5, 5]], + [' -512', [-512, 5]], + [' 1.20', [1.2, 6]], + ['123456', [123456, 6]], + [' 0.00', [0.00012, 6]], + [' 0.01', [0.012, 6]], + ['0.0', [0, 3]], + ['6.0', [6, 3]], + ['146', [145.66, 3]], + ] + + def test_output_has_expected_format(self): + for idx, (expected_output, args) in enumerate(self.expected_format_dataset): + with self.subTest(msg=f'Testing #{idx} "{args}" -> max length {expected_output}'): + actual_output = fmt_auto_float(*args) + print_verbose(actual_output, end=', ') + self.assertEqual(expected_output, actual_output) diff --git a/tests/util/test_util.py b/tests/util/test_util.py deleted file mode 100644 index 2417a27f..00000000 --- a/tests/util/test_util.py +++ /dev/null @@ -1,222 +0,0 @@ -# ----------------------------------------------------------------------------- -# pytermor [ANSI formatted terminal output toolset] -# (C) 2022 A. Shavykin <0.delameter@gmail.com> -# ----------------------------------------------------------------------------- -import unittest -import sys -from datetime import timedelta - -from pytermor import fmt, fmt_time_delta, fmt_prefixed_unit, PrefixedUnitFmtPreset, fmt_auto_float - - -def is_verbose_mode() -> bool: - return any([opt in sys.argv for opt in ['-v', '--verbose']]) - - -def print_verbose(value='', **argv): - if not is_verbose_mode(): - return - print(fmt.cyan(value or ""), flush=True, **argv) - - -class ReplaceSGRTestCase(unittest.TestCase): - pass # @TODO - - -class ReplaceCSITestCase(unittest.TestCase): - pass # @TODO - - -class ReplaceNonAsciiBytesTestCase(unittest.TestCase): - pass # @TODO - - -class FilterApplicationTestCase(unittest.TestCase): - pass # @TODO - - -class FormatAwareLeftAlignmentTestCase(unittest.TestCase): - pass # @TODO - - -class FormatAwareCenterAlignmentTestCase(unittest.TestCase): - pass # @TODO - - -class FormatTimeDeltaMaxLengthTestCase(unittest.TestCase): - expected_max_len_list = [3, 4, 6, 10, 9, 1000] - invalid_max_len_list = [-5, 0, 1, 2] - input_td_list = [ - timedelta(), - timedelta(microseconds=500), timedelta(milliseconds=200), - timedelta(seconds=1), timedelta(seconds=4), timedelta(seconds=10), - timedelta(seconds=20), timedelta(seconds=50), timedelta(minutes=1), timedelta(minutes=5), - timedelta(minutes=15), timedelta(minutes=30), timedelta(minutes=45), timedelta(minutes=59), - timedelta(hours=1, minutes=30), timedelta(hours=4, minutes=15), - timedelta(hours=8, minutes=59, seconds=59), timedelta(hours=12, minutes=30), - timedelta(hours=18, minutes=45), timedelta(hours=23, minutes=50), timedelta(days=1), - timedelta(days=3, hours=4), timedelta(days=5, hours=22, minutes=51), - timedelta(days=7, minutes=-1), timedelta(days=9), timedelta(days=12, hours=18), - timedelta(days=16, hours=2), timedelta(days=30), timedelta(days=55), timedelta(days=80), - timedelta(days=200), timedelta(days=350), timedelta(days=390), timedelta(days=810), - timedelta(days=10000), timedelta(days=100000), timedelta(days=400000), - timedelta(days=90000000), - timedelta(microseconds=-500), timedelta(milliseconds=-200), timedelta(seconds=-1), timedelta(seconds=-4), - timedelta(seconds=-10), timedelta(minutes=-5), timedelta(hours=-1, minutes=15), - timedelta(days=-1, hours=10, minutes=30), timedelta(days=-5), timedelta(days=-9, hours=-23), - timedelta(days=-100), timedelta(days=-300), timedelta(days=-1000) - ] - - def test_output_fits_in_required_length(self): - for expected_max_len in self.expected_max_len_list: - print_verbose(f'\nLEN={expected_max_len:d}', end=': ') - print_verbose(f'\nLEN={expected_max_len:d}', end=': ') - print_verbose(f'\nLEN={expected_max_len:d}', end=': ') - print_verbose(f'\nLEN={expected_max_len:d}', end=': ') - for input_td in self.input_td_list: - with self.subTest(msg=f'Testing "{input_td}" -> max length {expected_max_len}'): - actual_output = fmt_time_delta(input_td.total_seconds(), expected_max_len) - print_verbose(actual_output, end=', ') - self.assertGreaterEqual(expected_max_len, len(actual_output)) - - def test_invalid_max_length_fails(self): - for invalid_max_len in self.invalid_max_len_list: - with self.subTest(msg=f'Testing invalid max length {invalid_max_len}'): - self.assertRaises(ValueError, lambda: fmt_time_delta(100, invalid_max_len)) - - -class FormatTimeDeltaExpectedFormatTestCase(unittest.TestCase): - data_provider = [ - ['0 sec', timedelta()], - ['0 sec', timedelta(microseconds=500)], - ['1 sec', timedelta(seconds=1)], - ['10 secs', timedelta(seconds=10)], - ['1 min', timedelta(minutes=1)], - ['5 mins', timedelta(minutes=5)], - ['15 mins', timedelta(minutes=15)], - ['45 mins', timedelta(minutes=45)], - ['1h 30min', timedelta(hours=1, minutes=30)], - ['4h 15min', timedelta(hours=4, minutes=15)], - ['8h 59min', timedelta(hours=8, minutes=59, seconds=59)], - ['12h 30min', timedelta(hours=12, minutes=30)], - ['18h 45min', timedelta(hours=18, minutes=45)], - ['23h 50min', timedelta(hours=23, minutes=50)], - ['1d 0h', timedelta(days=1)], - ['3d 4h', timedelta(days=3, hours=4)], - ['5d 22h', timedelta(days=5, hours=22, minutes=51)], - ['6d 23h', timedelta(days=7, minutes=-1)], - ['9d 0h', timedelta(days=9)], - ['12 days', timedelta(days=12, hours=18)], - ['16 days', timedelta(days=16, hours=2)], - ['1 month', timedelta(days=30)], - ['1 month', timedelta(days=55)], - ['2 months', timedelta(days=80)], - ['6 months', timedelta(days=200)], - ['11 months', timedelta(days=350)], - ['1 year', timedelta(days=390)], - ['2 years', timedelta(days=810)], - ['27 years', timedelta(days=10000)], - ['277 years', timedelta(days=100000)], - ['OVERFLOW', timedelta(days=400000)], - ['OVERFLOW', timedelta(days=90000000)], - ['-0 sec', timedelta(microseconds=-500)], - ['-5 mins', timedelta(minutes=-5)], - ['-45 mins', timedelta(hours=-1, minutes=15)], - ['-13h 30min', timedelta(days=-1, hours=10, minutes=30)], - ['-5d 0h', timedelta(days=-5)], - ['-9d 23h', timedelta(days=-9, hours=-23)], - ['-3 months', timedelta(days=-100)], - ['-10 months', timedelta(days=-300)], - ['-2 years', timedelta(days=-1000)], - ] - - def test_output_has_expected_format(self): - for expected_output, input_td in self.data_provider: - with self.subTest(msg=f'Testing "{input_td}" -> "{expected_output}"'): - actual_output = fmt_time_delta(input_td.total_seconds(), 10) - print_verbose(actual_output, end=', ') - self.assertEqual(expected_output, actual_output) - - -class FormatPrefixedUnitMaxLengthTestCase(unittest.TestCase): - input_num_list = [1.1*pow(11, x) for x in range(1, 20)] - data_provider = [ - [6, PrefixedUnitFmtPreset(expand_to_max=True, max_value_len=5, unit_separator=None, unit=None)], - [7, PrefixedUnitFmtPreset(expand_to_max=True, max_value_len=4, unit_separator=' ', unit='m')], - [7, PrefixedUnitFmtPreset(max_value_len=4, unit_separator=' ', unit='m')], - [9, PrefixedUnitFmtPreset(max_value_len=6, prefixes=['1', '2', '3'], unit='U')], - [8, PrefixedUnitFmtPreset(expand_to_max=True, max_value_len=5, prefixes=['1', '2', '3'])], - [4, PrefixedUnitFmtPreset(max_value_len=3, unit_separator=None, unit=None, prefixes=['p'])], - [5, PrefixedUnitFmtPreset(max_value_len=3, unit_separator=None, unit='u', prefixes=['P'])], - [3, PrefixedUnitFmtPreset(max_value_len=1, mcoef=10, unit_separator=None, unit='g', prefixes=['1', '2', '3', '4', '5'])], - ] - - def test_output_fits_in_required_length(self): - for expected_max_len, preset in self.data_provider: - print_verbose(f'\nLEN={expected_max_len:d}', end=': ') - for input_num in self.input_num_list: - with self.subTest(msg=f'Testing "{input_num}" -> max length {expected_max_len}'): - actual_output = fmt_prefixed_unit(input_num, preset) - print_verbose(actual_output, end=', ') - self.assertGreaterEqual(expected_max_len, len(actual_output)) - - -class FormatPrefixedUnitExpectedFormatTestCase(unittest.TestCase): - data_provider = [ - ['0 b', 0], - ['1 b', 1], - ['10 b', 10], - ['43 b', 43], - ['180 b', 180], - ['631 b', 631], - ['1010 b', 1010], - ['1.00 kb', 1024], - ['1.05 kb', 1080], - ['6.08 kb', 6230], - ['14.65 kb', 15000], - ['44.14 kb', 45200], - ['130.2 kb', 133300], - ['1.20 Mb', 1257800], - ['41.11 Mb', 43106100], - ['668.0 Mb', 700500000], - ['2.33 Gb', 2501234567], - ['13.53 Gb', 14530231500], - ['142.4 Tb', 156530231500223], - ] - - def test_output_has_expected_format(self): - for expected_output, input_num in self.data_provider: - with self.subTest(msg=f'Testing {input_num} -> "{expected_output}"'): - actual_output = fmt_prefixed_unit(input_num) - print_verbose(actual_output, end=', ') - self.assertEqual(expected_output, actual_output) - - -class FormatAutoFloatExpectedFormatTestCase(unittest.TestCase): - data_provider = [ - ['1444', [1444, 4, True]], - [' 144', [144, 4, True]], - [' 14', [14, 4, True]], - ['300.0', [300, 5, False]], - ['30.00', [30, 5, False]], - [' 3.00', [3, 5, False]], - [' 0.30', [.3, 5, False]], - [' 0.03', [.03, 5, False]], - [' 0.00', [.003, 5, False]], - ['-5.00', [-5, 5, False]], - [' -512', [-512, 5, False]], - [' 1.20', [1.2, 6, False]], - ['123456', [123456, 6, False]], - [' 0.00', [0.00012, 6, False]], - [' 0.01', [0.012, 6, False]], - ['0.0', [0, 3, False]], - ['6.0', [6, 3, False]], - ['146', [145.66, 3, False]], - ] - - def test_output_has_expected_format(self): - for expected_output, args in self.data_provider: - with self.subTest(msg=f'Testing "{args}" -> max length {expected_output}'): - actual_output = fmt_auto_float(*args) - print_verbose(actual_output, end=', ') - self.assertEqual(expected_output, actual_output) From 0c21be9be807d488e630e3a9fd1fb739d6475232 Mon Sep 17 00:00:00 2001 From: delameter Date: Tue, 17 May 2022 06:46:45 +0300 Subject: [PATCH 2/4] - Utility classes reorganization. --- .env.dist | 2 +- README.md | 96 +++++++++- dev/readme/README.tpl.md | 96 +++++++++- pytermor/__init__.py | 14 +- pytermor/{util => }/common.py | 0 pytermor/numf/__init__.py | 19 ++ pytermor/{util => numf}/auto_float.py | 14 +- pytermor/{util => numf}/prefixed_unit.py | 26 +-- pytermor/{util => numf}/time_delta.py | 2 +- pytermor/{util => strf}/__init__.py | 12 -- pytermor/{util => strf}/fmtd.py | 0 pytermor/{util => strf}/string_filter.py | 0 setup.cfg | 2 +- tests/__init__.py | 6 + tests/common.py | 13 ++ tests/numf/__init__.py | 0 tests/numf/test_auto_float.py | 35 ++++ tests/numf/test_prefixed_unit.py | 83 ++++++++ tests/numf/test_time_delta.py | 105 +++++++++++ tests/strf/__init__.py | 13 ++ tests/test_util.py | 229 ----------------------- 21 files changed, 497 insertions(+), 270 deletions(-) rename pytermor/{util => }/common.py (100%) create mode 100644 pytermor/numf/__init__.py rename pytermor/{util => numf}/auto_float.py (69%) rename pytermor/{util => numf}/prefixed_unit.py (78%) rename pytermor/{util => numf}/time_delta.py (98%) rename pytermor/{util => strf}/__init__.py (65%) rename pytermor/{util => strf}/fmtd.py (100%) rename pytermor/{util => strf}/string_filter.py (100%) create mode 100644 tests/common.py create mode 100644 tests/numf/__init__.py create mode 100644 tests/numf/test_auto_float.py create mode 100644 tests/numf/test_prefixed_unit.py create mode 100644 tests/numf/test_time_delta.py create mode 100644 tests/strf/__init__.py delete mode 100644 tests/test_util.py diff --git a/.env.dist b/.env.dist index 5f39ce43..5c9fc978 100644 --- a/.env.dist +++ b/.env.dist @@ -1,3 +1,3 @@ -VERSION=1.7.4 +VERSION=1.8.0 PYPI_USERNAME=__token__ PYPI_PASSWORD= #api token diff --git a/README.md b/README.md index 79f4b969..488009b3 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ print(msg) -## API: String filters +## API: strf.StringFilter _StringFilter_ is common string modifier interface with dynamic configuration support. @@ -307,6 +307,93 @@ print(ascii_and_binary, '\n', result) +## API: strf.fmtd + +Set of methods to make working with SGR sequences a bit easier. + +- `ljust_fmtd` SGR-formatting-aware implementation of str.ljust() +- `rjust_fmtd` same, but for _str.rjust()_ +- `center_fmtd` same, but for _str.center()_ + + +## API: numf.* + +`pytermor` also includes a few helper formatters for numbers. + +
+Details (click) + +### format_auto_float + +Dynamically adjust decimal digit amount to fill the output string up with significant digits as much as possible. Universal solution for situations when you don't know exaclty what values will be displayed, but have fixed output width. Invocation: `format_auto_float(value, 4)`. + +| value | result | +| ----------: | ---------- | +| **1 234.56** | `"1235"` | +| **123.56** | `" 124"` | +| **12.56** | `"12.6"` | +| **1.56** | `"1.56"` | + + +### format_prefixed_unit + +Similar to previous method, but this one also supports metric prefixes and is highly customizable. Invocation: `format_prefixed_unit(value)`. + +| value | **631** | **1 080** | **45 200** | **1 257 800** | 4,31×10⁷ | 7,00×10⁸ | 2,50×10⁹ | +| :------: | :--------: | :--------: | :--------: | :--------: | :--------: | :--------: | :--------: | +| result | 631 b | 1.05 kb | 44.14 kb | 1.20 Mb | 41.11 Mb | 668.0 Mb | 2.33 Gb | + +| value | **1** | **0.1** | ... | +| :------: | :--------: | :--------: | :---: | +| result | 1.00 m | 0.10 m | @TODO | + +Settings example: +```python +PrefixedUnitPreset( + max_value_len=5, integer_input=True, + unit='b', unit_separator=' ', + mcoef=1024.0, + prefixes=[None, 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'], + prefix_zero_idx=0, +) +PrefixedUnitPreset( + max_value_len=7, integer_input=False, + unit='m', unit_separator='', + mcoef=1000.0, + prefixes=['y', 'z', 'a', 'f', 'p', 'n', 'μ', 'm', None], + prefix_zero_idx=8, +) +``` + +### format_time_delta + +Formats time interval in 4 different variants - 3-char, 4-char, 6-char and 10-char width output. Usage: `format_time_delta(seconds, max_len)`. + +| width | 2 | 10 | 60 | 2700 | 32 340 | 273 600 | 4 752 000 | 8,64×10⁸ | +| ------: | --- | --- | --- | --- | --- | --- | --- | --- | +| **3 chars** | 2s| 10s| 1m| 45m| 8h| 3d| 55d| -- | +| **4 chars** | 2 s | 10 s | 1 m | 45 m | 8 h | 3 d | 1 M | 27 y | +| **6 chars** | 2 sec | 10 sec | 1 min | 45 min| 8h 59m | 3d 4h | 1 mon | 27 yr | +| **10 chars** | 2 secs | 10 secs | 1 min | 45 mins| 8h 59m | 3d 4h | 1 months | 27 years | + +Settings example (for 10-char mode): +```python +TimeDeltaPreset([ + TimeUnit('sec', 60), + TimeUnit('min', 60, custom_short='min'), + TimeUnit('hour', 24, collapsible_after=24), + TimeUnit('day', 30, collapsible_after=10), + TimeUnit('month', 12), + TimeUnit('year', overflow_afer=999), +], allow_negative=True, + unit_separator=' ', + plural_suffix='s', + overflow_msg='OVERFLOW', +), +``` + +
+ ## API: Registries
@@ -827,6 +914,13 @@ You can of course create your own sequences and formats, but with one limitation ## Changelog +### v1.8.0 + +- `format_prefixed_unit` extended for working with decimal and binary metric prefixes. +- `format_time_delta` extended with new settings. +- Value rounding transferred from `format_auto_float` to `format_prefixed_unit` +- Utility classes reorganization. + ### v1.7.4 - Added 3 formatters: `fmt_prefixed_unit`, `fmt_time_delta`, `fmt_auto_float`. diff --git a/dev/readme/README.tpl.md b/dev/readme/README.tpl.md index 50b94bea..8ee4ca1a 100644 --- a/dev/readme/README.tpl.md +++ b/dev/readme/README.tpl.md @@ -181,7 +181,7 @@ Use `wrap()` method of _Format_ instance or call the instance itself to enclose
-## API: String filters +## API: strf.StringFilter _StringFilter_ is common string modifier interface with dynamic configuration support. @@ -212,6 +212,93 @@ Helper function `apply_filters` accepts both `StringFilter` implementation insta +## API: strf.fmtd + +Set of methods to make working with SGR sequences a bit easier. + +- `ljust_fmtd` SGR-formatting-aware implementation of str.ljust() +- `rjust_fmtd` same, but for _str.rjust()_ +- `center_fmtd` same, but for _str.center()_ + + +## API: numf.* + +`pytermor` also includes a few helper formatters for numbers. + +
+Details (click) + +### format_auto_float + +Dynamically adjust decimal digit amount to fill the output string up with significant digits as much as possible. Universal solution for situations when you don't know exaclty what values will be displayed, but have fixed output width. Invocation: `format_auto_float(value, 4)`. + +| value | result | +| ----------: | ---------- | +| **1 234.56** | `"1235"` | +| **123.56** | `" 124"` | +| **12.56** | `"12.6"` | +| **1.56** | `"1.56"` | + + +### format_prefixed_unit + +Similar to previous method, but this one also supports metric prefixes and is highly customizable. Invocation: `format_prefixed_unit(value)`. + +| value | **631** | **1 080** | **45 200** | **1 257 800** | 4,31×10⁷ | 7,00×10⁸ | 2,50×10⁹ | +| :------: | :--------: | :--------: | :--------: | :--------: | :--------: | :--------: | :--------: | +| result | 631 b | 1.05 kb | 44.14 kb | 1.20 Mb | 41.11 Mb | 668.0 Mb | 2.33 Gb | + +| value | **1** | **0.1** | ... | +| :------: | :--------: | :--------: | :---: | +| result | 1.00 m | 0.10 m | @TODO | + +Settings example: +```python +PrefixedUnitPreset( + max_value_len=5, integer_input=True, + unit='b', unit_separator=' ', + mcoef=1024.0, + prefixes=[None, 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'], + prefix_zero_idx=0, +) +PrefixedUnitPreset( + max_value_len=7, integer_input=False, + unit='m', unit_separator='', + mcoef=1000.0, + prefixes=['y', 'z', 'a', 'f', 'p', 'n', 'μ', 'm', None], + prefix_zero_idx=8, +) +``` + +### format_time_delta + +Formats time interval in 4 different variants - 3-char, 4-char, 6-char and 10-char width output. Usage: `format_time_delta(seconds, max_len)`. + +| width | 2 | 10 | 60 | 2700 | 32 340 | 273 600 | 4 752 000 | 8,64×10⁸ | +| ------: | --- | --- | --- | --- | --- | --- | --- | --- | +| **3 chars** | 2s| 10s| 1m| 45m| 8h| 3d| 55d| -- | +| **4 chars** | 2 s | 10 s | 1 m | 45 m | 8 h | 3 d | 1 M | 27 y | +| **6 chars** | 2 sec | 10 sec | 1 min | 45 min| 8h 59m | 3d 4h | 1 mon | 27 yr | +| **10 chars** | 2 secs | 10 secs | 1 min | 45 mins| 8h 59m | 3d 4h | 1 months | 27 years | + +Settings example (for 10-char mode): +```python +TimeDeltaPreset([ + TimeUnit('sec', 60), + TimeUnit('min', 60, custom_short='min'), + TimeUnit('hour', 24, collapsible_after=24), + TimeUnit('day', 30, collapsible_after=10), + TimeUnit('month', 12), + TimeUnit('year', overflow_afer=999), +], allow_negative=True, + unit_separator=' ', + plural_suffix='s', + overflow_msg='OVERFLOW', +), +``` + +
+ ## API: Registries
@@ -732,6 +819,13 @@ You can of course create your own sequences and formats, but with one limitation ## Changelog +### v1.8.0 + +- `format_prefixed_unit` extended for working with decimal and binary metric prefixes. +- `format_time_delta` extended with new settings. +- Value rounding transferred from `format_auto_float` to `format_prefixed_unit` +- Utility classes reorganization. + ### v1.7.4 - Added 3 formatters: `fmt_prefixed_unit`, `fmt_time_delta`, `fmt_auto_float`. diff --git a/pytermor/__init__.py b/pytermor/__init__.py index a37cdf2a..7bd37bee 100644 --- a/pytermor/__init__.py +++ b/pytermor/__init__.py @@ -4,7 +4,8 @@ # ----------------------------------------------------------------------------- from .seq import build, build_c256, build_rgb, SequenceSGR from .fmt import autof, Format -from .util import * +from .numf import * +from .strf import * __all__ = [ 'build', @@ -25,11 +26,12 @@ 'rjust_fmtd', 'center_fmtd', - 'fmt_auto_float', - 'fmt_prefixed_unit', - 'fmt_time_delta', + 'format_auto_float', + 'format_prefixed_unit', 'PrefixedUnitPreset', - 'SiPrefixedUnitPreset', + 'PRESET_SI_METRIC', + 'PRESET_SI_BINARY', + 'format_time_delta', 'TimeDeltaPreset', ] -__version__ = '1.7.4' +__version__ = '1.8.0' diff --git a/pytermor/util/common.py b/pytermor/common.py similarity index 100% rename from pytermor/util/common.py rename to pytermor/common.py diff --git a/pytermor/numf/__init__.py b/pytermor/numf/__init__.py new file mode 100644 index 00000000..264b5ac1 --- /dev/null +++ b/pytermor/numf/__init__.py @@ -0,0 +1,19 @@ +# ----------------------------------------------------------------------------- +# pytermor [ANSI formatted terminal output toolset] +# (C) 2022 A. Shavykin <0.delameter@gmail.com> +# ----------------------------------------------------------------------------- +from .auto_float import * +from .prefixed_unit import * +from .time_delta import * + +__all__ = [ + 'format_auto_float', + + 'format_prefixed_unit', + 'PrefixedUnitPreset', + 'PRESET_SI_METRIC', + 'PRESET_SI_BINARY', + + 'format_time_delta', + 'TimeDeltaPreset', +] diff --git a/pytermor/util/auto_float.py b/pytermor/numf/auto_float.py similarity index 69% rename from pytermor/util/auto_float.py rename to pytermor/numf/auto_float.py index 085c43fa..4b0e61f4 100644 --- a/pytermor/util/auto_float.py +++ b/pytermor/numf/auto_float.py @@ -5,16 +5,16 @@ from math import trunc -def fmt_auto_float(value: float, max_len: int) -> str: +def format_auto_float(value: float, max_len: int) -> str: """ - Dynamically adjust decimal digits amount to fill up output string - with significant digits as much as possible. + Dynamically adjust decimal digit amount to fill the output string + up with significant digits as much as possible. Examples: - - auto_float(1234.56, 4) -> 1235 - - auto_float( 123.56, 4) -> 124 - - auto_float( 12.56, 4) -> 12.6 - - auto_float( 1.56, 4) -> 1.56 + - format_auto_float(1234.56, 4) -> 1235 + - format_auto_float( 123.56, 4) -> 124 + - format_auto_float( 12.56, 4) -> 12.6 + - format_auto_float( 1.56, 4) -> 1.56 :param value: value to format :param max_len: maximum output string length (total) diff --git a/pytermor/util/prefixed_unit.py b/pytermor/numf/prefixed_unit.py similarity index 78% rename from pytermor/util/prefixed_unit.py rename to pytermor/numf/prefixed_unit.py index e1c2b757..ddbd4973 100644 --- a/pytermor/util/prefixed_unit.py +++ b/pytermor/numf/prefixed_unit.py @@ -8,7 +8,7 @@ from math import trunc from typing import List -from . import fmt_auto_float +from . import format_auto_float @dataclass @@ -33,16 +33,16 @@ class PrefixedUnitPreset: minimum requirement for displaying values from 999 to -999. Next number to 999 is 1000, which will be displayed as 1k. """ -FMT_PRESET_SI_METRIC = PrefixedUnitPreset( +PRESET_SI_METRIC = PrefixedUnitPreset( max_value_len=5, integer_input=False, - unit='V', + unit='m', unit_separator=' ', mcoef=1000.0, prefixes=PREFIXES_SI, prefix_zero_idx=PREFIX_ZERO_SI, ) -FMT_PRESET_SI_BINARY = PrefixedUnitPreset( +PRESET_SI_BINARY = PrefixedUnitPreset( max_value_len=5, integer_input=True, unit='b', @@ -53,19 +53,23 @@ class PrefixedUnitPreset: ) -def fmt_prefixed_unit(value: float, preset: PrefixedUnitPreset = None) -> str: +def format_prefixed_unit(value: float, preset: PrefixedUnitPreset = None) -> str: """ Format *value* using *preset* settings. The main idea of this method is to fit into specified string length as much significant digits as it's - theoretically possible, using increasing coefficients and unit prefixes to - indicate power. + theoretically possible, using multipliers and unit prefixes to + indicate them. + + Default *preset* is PRESET_SI_BINARY, PrefixedUnitPreset with base 1024 + made for binary sizes (bytes, kbytes, Mbytes). :param value: input value - :param preset: formatter settings, default is SiPrefixedUnitPreset with base 1024 + :param preset: formatter settings :return: formatted value + :rtype: str """ if preset is None: - preset = FMT_PRESET_SI_BINARY + preset = PRESET_SI_BINARY prefixes = preset.prefixes or [''] unit_separator = preset.unit_separator or '' @@ -81,12 +85,12 @@ def fmt_prefixed_unit(value: float, preset: PrefixedUnitPreset = None) -> str: unit_idx += 1 continue - unit_full = f'{prefixes[unit_idx] or ""}{preset.unit or ""}' + unit_full = (prefixes[unit_idx] or '') + (preset.unit or '') if preset.integer_input and unit_idx == preset.prefix_zero_idx: num_str = f'{trunc(value)!s:.{preset.max_value_len}s}' else: - num_str = fmt_auto_float(value, preset.max_value_len) + num_str = format_auto_float(value, preset.max_value_len) return f'{num_str.strip()}{unit_separator}{unit_full}' diff --git a/pytermor/util/time_delta.py b/pytermor/numf/time_delta.py similarity index 98% rename from pytermor/util/time_delta.py rename to pytermor/numf/time_delta.py index 5a910bc4..8f4cc24a 100644 --- a/pytermor/util/time_delta.py +++ b/pytermor/numf/time_delta.py @@ -82,7 +82,7 @@ class TimeDeltaPreset: } -def fmt_time_delta(seconds: float, max_len: int = None) -> str: +def format_time_delta(seconds: float, max_len: int = None) -> str: """ Format time delta using suitable format (which depends on *max_len* argument). Key feature of this formatter is diff --git a/pytermor/util/__init__.py b/pytermor/strf/__init__.py similarity index 65% rename from pytermor/util/__init__.py rename to pytermor/strf/__init__.py index 948b7bb8..2de7411e 100644 --- a/pytermor/util/__init__.py +++ b/pytermor/strf/__init__.py @@ -5,10 +5,6 @@ from .string_filter import * from .fmtd import * -from .auto_float import * -from .prefixed_unit import * -from .time_delta import * - __all__ = [ 'apply_filters', 'StringFilter', @@ -19,12 +15,4 @@ 'ljust_fmtd', 'rjust_fmtd', 'center_fmtd', - - 'fmt_prefixed_unit', - 'PrefixedUnitPreset', - 'FMT_PRESET_SI_METRIC', - 'FMT_PRESET_SI_BINARY', - 'fmt_time_delta', - 'TimeDeltaPreset', - 'fmt_auto_float', ] diff --git a/pytermor/util/fmtd.py b/pytermor/strf/fmtd.py similarity index 100% rename from pytermor/util/fmtd.py rename to pytermor/strf/fmtd.py diff --git a/pytermor/util/string_filter.py b/pytermor/strf/string_filter.py similarity index 100% rename from pytermor/util/string_filter.py rename to pytermor/strf/string_filter.py diff --git a/setup.cfg b/setup.cfg index caf69bf1..d2b8f3fe 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = pytermor -version = 1.7.4 +version = 1.8.0 author = Aleksandr Shavykin author_email = 0.delameter@gmail.com description = ANSI formatted terminal output library diff --git a/tests/__init__.py b/tests/__init__.py index e69de29b..f028b28c 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,6 @@ +from .common import * + +__all__ = [ + 'is_verbose_mode', + 'print_verbose', +] diff --git a/tests/common.py b/tests/common.py new file mode 100644 index 00000000..ea7b6534 --- /dev/null +++ b/tests/common.py @@ -0,0 +1,13 @@ +import sys + +from pytermor import fmt + + +def is_verbose_mode() -> bool: + return any([opt in sys.argv for opt in ['-v', '--verbose']]) + + +def print_verbose(value='', **argv): + if not is_verbose_mode(): + return + print(fmt.cyan(value or ""), flush=True, **argv) diff --git a/tests/numf/__init__.py b/tests/numf/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/numf/test_auto_float.py b/tests/numf/test_auto_float.py new file mode 100644 index 00000000..f957e6e4 --- /dev/null +++ b/tests/numf/test_auto_float.py @@ -0,0 +1,35 @@ +# ----------------------------------------------------------------------------- +# pytermor [ANSI formatted terminal output toolset] +# (C) 2022 A. Shavykin <0.delameter@gmail.com> +# ----------------------------------------------------------------------------- +import unittest + +from pytermor import format_auto_float +from tests import print_verbose + + +class TestAutoFloat(unittest.TestCase): + expected_format_dataset = [ + ['300.0', [300, 5]], + ['30.00', [30, 5]], + [' 3.00', [3, 5]], + [' 0.30', [.3, 5]], + [' 0.03', [.03, 5]], + [' 0.00', [.003, 5]], + ['-5.00', [-5, 5]], + [' -512', [-512, 5]], + [' 1.20', [1.2, 6]], + ['123456', [123456, 6]], + [' 0.00', [0.00012, 6]], + [' 0.01', [0.012, 6]], + ['0.0', [0, 3]], + ['6.0', [6, 3]], + ['146', [145.66, 3]], + ] + + def test_output_has_expected_format(self): + for idx, (expected_output, args) in enumerate(self.expected_format_dataset): + with self.subTest(msg=f'Testing #{idx} "{args}" -> max length {expected_output}'): + actual_output = format_auto_float(*args) + print_verbose(actual_output, end=', ') + self.assertEqual(expected_output, actual_output) \ No newline at end of file diff --git a/tests/numf/test_prefixed_unit.py b/tests/numf/test_prefixed_unit.py new file mode 100644 index 00000000..8e2031fe --- /dev/null +++ b/tests/numf/test_prefixed_unit.py @@ -0,0 +1,83 @@ +# ----------------------------------------------------------------------------- +# pytermor [ANSI formatted terminal output toolset] +# (C) 2022 A. Shavykin <0.delameter@gmail.com> +# ----------------------------------------------------------------------------- +import unittest + +from pytermor import format_prefixed_unit, PRESET_SI_BINARY, PRESET_SI_METRIC, PrefixedUnitPreset +from tests import print_verbose + + +class TestPrefixedUnit(unittest.TestCase): + expected_format_high_dataset = [ + ['-142 Tb', -156530231500223], ['-13.5 Gb', -14530231500], + ['-2.33 Gb', -2501234567], ['-668 Mb', -700500000], + ['-41.1 Mb', -43106100], ['-1.20 Mb', -1257800], + ['-130 kb', -133300], ['-44.1 kb', -45200], + ['-14.6 kb', -15000], ['-6.08 kb', -6230], + ['-1.05 kb', -1080], ['-1.00 kb', -1024], + ['-1010 b', -1010], ['-631 b', -631], + ['-180 b', -180], ['-43 b', -43], + ['-10 b', -10], ['-1 b', -1], + ['0 b', 0], + ['1 b', 1], ['10 b', 10], + ['43 b', 43], ['180 b', 180], + ['631 b', 631], ['1010 b', 1010], + ['1.00 kb', 1024], ['1.05 kb', 1080], + ['6.08 kb', 6230], ['14.65 kb', 15000], + ['44.14 kb', 45200], ['130.2 kb', 133300], + ['1.20 Mb', 1257800], ['41.11 Mb', 43106100], + ['668.0 Mb', 700500000], ['2.33 Gb', 2501234567], + ['13.53 Gb', 14530231500], ['142.4 Tb', 156530231500223], + ] + + def test_output_high_has_expected_format(self): + for idx, (expected_output, input_num) in enumerate(self.expected_format_high_dataset): + with self.subTest(msg=f'Testing #{idx} {input_num} -> "{expected_output}"'): + actual_output = format_prefixed_unit(input_num, PRESET_SI_BINARY) + print_verbose(actual_output, end=', ') + self.assertEqual(expected_output, actual_output) + + # @TODO incomplete + expected_format_low_dataset = [ + ['1.00 m', 1], + ['0.10 m', 0.1], + ['0.01 m', 1e-2], # @FIXME 10.0mm + ['1.00 mm', 1e-3], + ] + + def test_output_low_has_expected_format(self): + for idx, (expected_output, input_num) in enumerate(self.expected_format_low_dataset): + with self.subTest(msg=f'Testing #{idx} {input_num} -> "{expected_output}"'): + actual_output = format_prefixed_unit(input_num, PRESET_SI_METRIC) + print_verbose(actual_output, end=', ') + self.assertEqual(expected_output, actual_output) + + req_len_dataset = [ + [8, PRESET_SI_METRIC], + [8, PRESET_SI_BINARY], + [5, PrefixedUnitPreset( + max_value_len=3, integer_input=True, mcoef=1000.0, + prefixes=PRESET_SI_METRIC.prefixes, + prefix_zero_idx=PRESET_SI_METRIC.prefix_zero_idx, + unit=None, unit_separator=None, + )], + [6, PrefixedUnitPreset( + max_value_len=4, integer_input=True, mcoef=1000.0, + prefixes=PRESET_SI_METRIC.prefixes, + prefix_zero_idx=PRESET_SI_METRIC.prefix_zero_idx, + unit=None, unit_separator=None, + )], + ] + req_len_input_num_list = [.076 * pow(11, x) * (-1 * (x % 2)) for x in range(-20, 20)] + + def test_output_fits_in_required_length(self): + for preset_idx, (expected_max_len, preset) in enumerate(self.req_len_dataset): + print_verbose(f'\nLEN={expected_max_len:d}', end=': ') + for input_idx, input_num in enumerate(self.req_len_input_num_list): + with self.subTest(msg=f'Testing P{preset_idx} #{input_idx} "{input_num}" -> max length {expected_max_len}'): + actual_output = format_prefixed_unit(input_num, preset) + print_verbose(actual_output, end=', ') + self.assertGreaterEqual(expected_max_len, + len(actual_output), + f'Actual output ("{actual_output}") exceeds maximum') \ No newline at end of file diff --git a/tests/numf/test_time_delta.py b/tests/numf/test_time_delta.py new file mode 100644 index 00000000..59077159 --- /dev/null +++ b/tests/numf/test_time_delta.py @@ -0,0 +1,105 @@ +# ----------------------------------------------------------------------------- +# pytermor [ANSI formatted terminal output toolset] +# (C) 2022 A. Shavykin <0.delameter@gmail.com> +# ----------------------------------------------------------------------------- +import unittest +from datetime import timedelta + +from pytermor import format_time_delta +from tests import print_verbose + + +class TestTimeDelta(unittest.TestCase): + expected_format_max_len = 10 + expected_format_dataset = [ + ['OVERFLOW', timedelta(days=-700000)], + ['-2 years', timedelta(days=-1000)], + ['-10 months', timedelta(days=-300)], + ['-3 months', timedelta(days=-100)], + ['-9d 23h', timedelta(days=-9, hours=-23)], + ['-5d 0h', timedelta(days=-5)], + ['-13h 30min', timedelta(days=-1, hours=10, minutes=30)], + ['-45 mins', timedelta(hours=-1, minutes=15)], + ['-5 mins', timedelta(minutes=-5)], + ['-2 secs', timedelta(seconds=-2.01)], + ['-2 secs', timedelta(seconds=-2)], + ['-1 sec', timedelta(seconds=-2, microseconds=1)], + ['-1 sec', timedelta(seconds=-1.9)], + ['-1 sec', timedelta(seconds=-1.1)], + ['-1 sec', timedelta(seconds=-1.0)], + ['~0 secs', timedelta(seconds=-0.5)], + ['~0 secs', timedelta(milliseconds=-50)], + ['~0 secs', timedelta(microseconds=-100)], + ['~0 secs', timedelta(microseconds=-1)], + ['0 secs', timedelta()], + ['0 secs', timedelta(microseconds=500)], + ['<1 sec', timedelta(milliseconds=25)], + ['<1 sec', timedelta(seconds=0.1)], + ['<1 sec', timedelta(seconds=0.9)], + ['1 sec', timedelta(seconds=1)], + ['1 sec', timedelta(seconds=1.0)], + ['1 sec', timedelta(seconds=1.1)], + ['1 sec', timedelta(seconds=1.9)], + ['1 sec', timedelta(seconds=2, microseconds=-1)], + ['2 secs', timedelta(seconds=2)], + ['2 secs', timedelta(seconds=2.0)], + ['2 secs', timedelta(seconds=2.5)], + ['10 secs', timedelta(seconds=10)], + ['1 min', timedelta(minutes=1)], + ['5 mins', timedelta(minutes=5)], + ['15 mins', timedelta(minutes=15)], + ['45 mins', timedelta(minutes=45)], + ['1h 30min', timedelta(hours=1, minutes=30)], + ['4h 15min', timedelta(hours=4, minutes=15)], + ['8h 59min', timedelta(hours=8, minutes=59, seconds=59)], + ['12h 30min', timedelta(hours=12, minutes=30)], + ['18h 45min', timedelta(hours=18, minutes=45)], + ['23h 50min', timedelta(hours=23, minutes=50)], + ['1d 0h', timedelta(days=1)], + ['3d 4h', timedelta(days=3, hours=4)], + ['5d 22h', timedelta(days=5, hours=22, minutes=51)], + ['6d 23h', timedelta(days=7, minutes=-1)], + ['9d 0h', timedelta(days=9)], + ['12 days', timedelta(days=12, hours=18)], + ['16 days', timedelta(days=16, hours=2)], + ['1 month', timedelta(days=30)], + ['1 month', timedelta(days=55)], + ['2 months', timedelta(days=70)], + ['2 months', timedelta(days=80)], + ['6 months', timedelta(days=200)], + ['11 months', timedelta(days=350)], + ['1 year', timedelta(days=390)], + ['2 years', timedelta(days=810)], + ['27 years', timedelta(days=10000)], + ['277 years', timedelta(days=100000)], + ['OVERFLOW', timedelta(days=400000)], + ] + + def test_output_has_expected_format(self): + for idx, (expected_output, input_arg) in enumerate(self.expected_format_dataset): + with self.subTest(msg=f'Test #{idx}: "{input_arg}" -> "{expected_output}"'): + if type(input_arg) is not timedelta: + input_arg = timedelta(days=input_arg) + + actual_output = format_time_delta(input_arg.total_seconds(), self.expected_format_max_len) + print_verbose(actual_output, end=', ') + self.assertEqual(expected_output, actual_output) + + req_len_expected_len_list = [3, 4, 6, 10, 9, 1000] + req_len_input_delta_list = [el[1] for el in expected_format_dataset] + + def test_output_fits_in_required_length(self): + for idx, expected_max_len in enumerate(self.req_len_expected_len_list): + print_verbose(f'\nLEN={expected_max_len:d}', end=': ') + for input_td in self.req_len_input_delta_list: + with self.subTest(msg=f'Testing #{idx} "{input_td}" -> max length {expected_max_len}'): + actual_output = format_time_delta(input_td.total_seconds(), expected_max_len) + print_verbose(actual_output, end=', ') + self.assertGreaterEqual(expected_max_len, len(actual_output)) + + invalid_len_list = [-5, 0, 1, 2] + + def test_invalid_max_length_fails(self): + for invalid_max_len in self.invalid_len_list: + with self.subTest(msg=f'Testing invalid max length {invalid_max_len}'): + self.assertRaises(ValueError, lambda: format_time_delta(100, invalid_max_len)) \ No newline at end of file diff --git a/tests/strf/__init__.py b/tests/strf/__init__.py new file mode 100644 index 00000000..b25aa733 --- /dev/null +++ b/tests/strf/__init__.py @@ -0,0 +1,13 @@ +# ----------------------------------------------------------------------------- +# pytermor [ANSI formatted terminal output toolset] +# (C) 2022 A. Shavykin <0.delameter@gmail.com> +# ----------------------------------------------------------------------------- +import unittest + + +class TestStringFilter(unittest.TestCase): + pass # @TODO + + +class TestFmtd(unittest.TestCase): + pass # @TODO diff --git a/tests/test_util.py b/tests/test_util.py deleted file mode 100644 index 22a3f1a0..00000000 --- a/tests/test_util.py +++ /dev/null @@ -1,229 +0,0 @@ -# ----------------------------------------------------------------------------- -# pytermor [ANSI formatted terminal output toolset] -# (C) 2022 A. Shavykin <0.delameter@gmail.com> -# ----------------------------------------------------------------------------- -import sys -import unittest -from datetime import timedelta - -from pytermor import fmt, fmt_time_delta, fmt_prefixed_unit, fmt_auto_float, PrefixedUnitPreset, \ - FMT_PRESET_SI_METRIC, FMT_PRESET_SI_BINARY - - -def is_verbose_mode() -> bool: - return any([opt in sys.argv for opt in ['-v', '--verbose']]) - - -def print_verbose(value='', **argv): - if not is_verbose_mode(): - return - print(fmt.cyan(value or ""), flush=True, **argv) - - -class TestStringFilter(unittest.TestCase): - pass # @TODO - - -class TestFormatAwareAlignment(unittest.TestCase): - pass # @TODO - - -class TestPrefixedUnit(unittest.TestCase): - expected_format_dataset = [ - ['-142 Tb', -156530231500223], - ['-13.5 Gb', -14530231500], - ['-2.33 Gb', -2501234567], - ['-668 Mb', -700500000], - ['-41.1 Mb', -43106100], - ['-1.20 Mb', -1257800], - ['-130 kb', -133300], - ['-44.1 kb', -45200], - ['-14.6 kb', -15000], - ['-6.08 kb', -6230], - ['-1.05 kb', -1080], - ['-1.00 kb', -1024], - ['-1010 b', -1010], - ['-631 b', -631], - ['-180 b', -180], - ['-43 b', -43], - ['-10 b', -10], - ['-1 b', -1], - ['0 b', 0], - ['1 b', 1], - ['10 b', 10], - ['43 b', 43], - ['180 b', 180], - ['631 b', 631], - ['1010 b', 1010], - ['1.00 kb', 1024], - ['1.05 kb', 1080], - ['6.08 kb', 6230], - ['14.65 kb', 15000], - ['44.14 kb', 45200], - ['130.2 kb', 133300], - ['1.20 Mb', 1257800], - ['41.11 Mb', 43106100], - ['668.0 Mb', 700500000], - ['2.33 Gb', 2501234567], - ['13.53 Gb', 14530231500], - ['142.4 Tb', 156530231500223], - ] - - def test_output_has_expected_format(self): - for idx, (expected_output, input_num) in enumerate(self.expected_format_dataset): - with self.subTest(msg=f'Testing #{idx} {input_num} -> "{expected_output}"'): - actual_output = fmt_prefixed_unit(input_num) - print_verbose(actual_output, end=', ') - self.assertEqual(expected_output, actual_output) - - req_len_dataset = [ - [8, FMT_PRESET_SI_METRIC], - [8, FMT_PRESET_SI_BINARY], - [5, PrefixedUnitPreset( - max_value_len=3, integer_input=True, mcoef=1000.0, - prefixes=FMT_PRESET_SI_METRIC.prefixes, - prefix_zero_idx=FMT_PRESET_SI_METRIC.prefix_zero_idx, - unit=None, unit_separator=None, - )], - [6, PrefixedUnitPreset( - max_value_len=4, integer_input=True, mcoef=1000.0, - prefixes=FMT_PRESET_SI_METRIC.prefixes, - prefix_zero_idx=FMT_PRESET_SI_METRIC.prefix_zero_idx, - unit=None, unit_separator=None, - )], - ] - req_len_input_num_list = [.076 * pow(11, x) * (-1 * (x % 2)) for x in range(-20, 20)] - - def test_output_fits_in_required_length(self): - for preset_idx, (expected_max_len, preset) in enumerate(self.req_len_dataset): - print_verbose(f'\nLEN={expected_max_len:d}', end=': ') - for input_idx, input_num in enumerate(self.req_len_input_num_list): - with self.subTest(msg=f'Testing P{preset_idx} #{input_idx} "{input_num}" -> max length {expected_max_len}'): - actual_output = fmt_prefixed_unit(input_num, preset) - print_verbose(actual_output, end=', ') - self.assertGreaterEqual(expected_max_len, - len(actual_output), - f'Actual output ("{actual_output}") exceeds maximum') - - -class TestTimeDelta(unittest.TestCase): - expected_format_max_len = 10 - expected_format_dataset = [ - ['OVERFLOW', timedelta(days=-700000)], - ['-2 years', timedelta(days=-1000)], - ['-10 months', timedelta(days=-300)], - ['-3 months', timedelta(days=-100)], - ['-9d 23h', timedelta(days=-9, hours=-23)], - ['-5d 0h', timedelta(days=-5)], - ['-13h 30min', timedelta(days=-1, hours=10, minutes=30)], - ['-45 mins', timedelta(hours=-1, minutes=15)], - ['-5 mins', timedelta(minutes=-5)], - ['-2 secs', timedelta(seconds=-2.01)], - ['-2 secs', timedelta(seconds=-2)], - ['-1 sec', timedelta(seconds=-2, microseconds=1)], - ['-1 sec', timedelta(seconds=-1.9)], - ['-1 sec', timedelta(seconds=-1.1)], - ['-1 sec', timedelta(seconds=-1.0)], - ['~0 secs', timedelta(seconds=-0.5)], - ['~0 secs', timedelta(milliseconds=-50)], - ['~0 secs', timedelta(microseconds=-100)], - ['~0 secs', timedelta(microseconds=-1)], - ['0 secs', timedelta()], - ['0 secs', timedelta(microseconds=500)], - ['<1 sec', timedelta(milliseconds=25)], - ['<1 sec', timedelta(seconds=0.1)], - ['<1 sec', timedelta(seconds=0.9)], - ['1 sec', timedelta(seconds=1)], - ['1 sec', timedelta(seconds=1.0)], - ['1 sec', timedelta(seconds=1.1)], - ['1 sec', timedelta(seconds=1.9)], - ['1 sec', timedelta(seconds=2, microseconds=-1)], - ['2 secs', timedelta(seconds=2)], - ['2 secs', timedelta(seconds=2.0)], - ['2 secs', timedelta(seconds=2.5)], - ['10 secs', timedelta(seconds=10)], - ['1 min', timedelta(minutes=1)], - ['5 mins', timedelta(minutes=5)], - ['15 mins', timedelta(minutes=15)], - ['45 mins', timedelta(minutes=45)], - ['1h 30min', timedelta(hours=1, minutes=30)], - ['4h 15min', timedelta(hours=4, minutes=15)], - ['8h 59min', timedelta(hours=8, minutes=59, seconds=59)], - ['12h 30min', timedelta(hours=12, minutes=30)], - ['18h 45min', timedelta(hours=18, minutes=45)], - ['23h 50min', timedelta(hours=23, minutes=50)], - ['1d 0h', timedelta(days=1)], - ['3d 4h', timedelta(days=3, hours=4)], - ['5d 22h', timedelta(days=5, hours=22, minutes=51)], - ['6d 23h', timedelta(days=7, minutes=-1)], - ['9d 0h', timedelta(days=9)], - ['12 days', timedelta(days=12, hours=18)], - ['16 days', timedelta(days=16, hours=2)], - ['1 month', timedelta(days=30)], - ['1 month', timedelta(days=55)], - ['2 months', timedelta(days=70)], - ['2 months', timedelta(days=80)], - ['6 months', timedelta(days=200)], - ['11 months', timedelta(days=350)], - ['1 year', timedelta(days=390)], - ['2 years', timedelta(days=810)], - ['27 years', timedelta(days=10000)], - ['277 years', timedelta(days=100000)], - ['OVERFLOW', timedelta(days=400000)], - ] - - def test_output_has_expected_format(self): - for idx, (expected_output, input_arg) in enumerate(self.expected_format_dataset): - with self.subTest(msg=f'Test #{idx}: "{input_arg}" -> "{expected_output}"'): - if type(input_arg) is not timedelta: - input_arg = timedelta(days=input_arg) - - actual_output = fmt_time_delta(input_arg.total_seconds(), self.expected_format_max_len) - print_verbose(actual_output, end=', ') - self.assertEqual(expected_output, actual_output) - - req_len_expected_len_list = [3, 4, 6, 10, 9, 1000] - req_len_input_delta_list = [el[1] for el in expected_format_dataset] - - def test_output_fits_in_required_length(self): - for idx, expected_max_len in enumerate(self.req_len_expected_len_list): - print_verbose(f'\nLEN={expected_max_len:d}', end=': ') - for input_td in self.req_len_input_delta_list: - with self.subTest(msg=f'Testing #{idx} "{input_td}" -> max length {expected_max_len}'): - actual_output = fmt_time_delta(input_td.total_seconds(), expected_max_len) - print_verbose(actual_output, end=', ') - self.assertGreaterEqual(expected_max_len, len(actual_output)) - - invalid_len_list = [-5, 0, 1, 2] - - def test_invalid_max_length_fails(self): - for invalid_max_len in self.invalid_len_list: - with self.subTest(msg=f'Testing invalid max length {invalid_max_len}'): - self.assertRaises(ValueError, lambda: fmt_time_delta(100, invalid_max_len)) - - -class TestAutoFloat(unittest.TestCase): - expected_format_dataset = [ - ['300.0', [300, 5]], - ['30.00', [30, 5]], - [' 3.00', [3, 5]], - [' 0.30', [.3, 5]], - [' 0.03', [.03, 5]], - [' 0.00', [.003, 5]], - ['-5.00', [-5, 5]], - [' -512', [-512, 5]], - [' 1.20', [1.2, 6]], - ['123456', [123456, 6]], - [' 0.00', [0.00012, 6]], - [' 0.01', [0.012, 6]], - ['0.0', [0, 3]], - ['6.0', [6, 3]], - ['146', [145.66, 3]], - ] - - def test_output_has_expected_format(self): - for idx, (expected_output, args) in enumerate(self.expected_format_dataset): - with self.subTest(msg=f'Testing #{idx} "{args}" -> max length {expected_output}'): - actual_output = fmt_auto_float(*args) - print_verbose(actual_output, end=', ') - self.assertEqual(expected_output, actual_output) From 4e421b3680765f7c8792a056d1e13c7f24e6b59c Mon Sep 17 00:00:00 2001 From: delameter Date: Wed, 18 May 2022 08:36:24 +0300 Subject: [PATCH 3/4] - Unit tests output formatting; - `noop` SGR sequence and `noop` format. --- Makefile | 18 ++++- dev/readme/README.tpl.md | 34 ++++---- dev/runConfigurations/run-debug.run.xml | 24 ++++++ dev/runConfigurations/run.run.xml | 24 ++++++ pytermor/fmt.py | 17 ++-- pytermor/numf/prefixed_unit.py | 32 +++++--- pytermor/seq.py | 19 ++++- pytermor/sgr.py | 4 + requirements-dev.txt | 2 + tests/__init__.py | 37 ++++++++- tests/common.py | 13 --- tests/numf/test_auto_float.py | 13 ++- tests/numf/test_prefixed_unit.py | 103 +++++++++++++----------- tests/numf/test_time_delta.py | 39 ++++++--- tests/run.py | 66 +++++++++++++++ tests/test_fmt.py | 4 +- tests/test_seq.py | 10 +-- 17 files changed, 343 insertions(+), 116 deletions(-) create mode 100644 dev/runConfigurations/run-debug.run.xml create mode 100644 dev/runConfigurations/run.run.xml create mode 100644 requirements-dev.txt delete mode 100644 tests/common.py create mode 100644 tests/run.py diff --git a/Makefile b/Makefile index 85f84d59..74222bc0 100644 --- a/Makefile +++ b/Makefile @@ -27,8 +27,22 @@ cleanup: prepare: python3 -m pip install --upgrade build twine +init-venv: + python3 -m venv venv + . venv/bin/activate + pip3 install -r requirements-dev.txt + test: ## Run tests - PYTHONPATH=${PWD} python3 -s -m unittest -v + . venv/bin/activate + PYTHONPATH=${PWD} python3 -s tests/run.py + +test-verbose: ## Run tests with detailed output + . venv/bin/activate + PYTHONPATH=${PWD} python3 -s tests/run.py -v + +test-debug: ## Debug tests + . venv/bin/activate + PYTHONPATH=${PWD} python3 -s tests/run.py -vv set-version: ## Set new package version @echo "Current version: ${YELLOW}${VERSION}${RESET}" @@ -39,9 +53,11 @@ set-version: ## Set new package version echo "Updated version: ${GREEN}$$VERSION${RESET}" generate-readme: ## Generate README file + . venv/bin/activate PYTHONPATH=${PWD} python3 -s dev/readme/generate.py generate-thumbs: ## Generate README examples' thumbnails + . venv/bin/activate PYTHONPATH=${PWD} python3 -s dev/readme/generate_thumbs.py build: ## Build module diff --git a/dev/readme/README.tpl.md b/dev/readme/README.tpl.md index 8ee4ca1a..9b3be447 100644 --- a/dev/readme/README.tpl.md +++ b/dev/readme/README.tpl.md @@ -82,9 +82,9 @@ However, there is an option to specify what attributes should be disabled or let As you can see, the update went well — we kept all the previously applied formatting. Of course, this method cannot be 100% applicable — for example, imagine that original text was colored blue. After the update "string" word won't be blue anymore, as we used `COLOR_OFF` escape sequence to neutralize our own yellow color. But it still can be helpful for a majority of cases (especially when text is generated and formatted by the same program and in one go). -## API: module +## API: pytermor -### autof +### > `autof` Signature: `autof(*params str|int|SequenceSGR) -> Format` @@ -97,7 +97,7 @@ Each sequence param can be specified as: - integer param value - existing _SequenceSGR_ instance (params will be extracted) -### build +### > `build` Signature: `build(*params str|int|SequenceSGR) -> SequenceSGR` @@ -105,13 +105,13 @@ Create new _SequenceSGR_ with specified params. Resulting sequence params order _SequenceSGR_ with zero params was specifically implemented to translate into empty string and not into `\e[m`, which wolud make sense, but also would be very entangling, as it's equivavlent of `\e[0m` — **hard reset** sequence. -### build_c256 +### > `build_c256` Signature:`build_c256(color: int, bg: bool = False) -> SequenceSGR` Create new _SequenceSGR_ that sets foreground color or background color, depending on `bg` value, in 256-color mode. Valid values for `color` are [0; 255], see more at [↗ xterm-256 colors](https://www.ditig.com/256-colors-cheat-sheet) page. -### build_rgb +### > `build_rgb` Signature:`build_rgb(r: int, g: int, b: int, bg: bool = False) -> SequenceSGR` @@ -120,7 +120,7 @@ Create new _SequenceSGR_ that sets foreground color or background color, dependi ## API: SGR sequences -Class representing SGR-mode ANSI escape sequence with varying amount of parameters. +Class representing SGR-type ANSI escape sequence with varying amount of parameters.
Details (click) @@ -216,9 +216,9 @@ Helper function `apply_filters` accepts both `StringFilter` implementation insta Set of methods to make working with SGR sequences a bit easier. -- `ljust_fmtd` SGR-formatting-aware implementation of str.ljust() -- `rjust_fmtd` same, but for _str.rjust()_ -- `center_fmtd` same, but for _str.center()_ +- `ljust_fmtd()` SGR-formatting-aware implementation of str.ljust() +- `rjust_fmtd()` same, but for _str.rjust()_ +- `center_fmtd()` same, but for _str.center()_ ## API: numf.* @@ -228,7 +228,7 @@ Set of methods to make working with SGR sequences a bit easier.
Details (click) -### format_auto_float +### > `format_auto_float` Dynamically adjust decimal digit amount to fill the output string up with significant digits as much as possible. Universal solution for situations when you don't know exaclty what values will be displayed, but have fixed output width. Invocation: `format_auto_float(value, 4)`. @@ -240,7 +240,7 @@ Dynamically adjust decimal digit amount to fill the output string up with signif | **1.56** | `"1.56"` | -### format_prefixed_unit +### > `format_prefixed_unit` Similar to previous method, but this one also supports metric prefixes and is highly customizable. Invocation: `format_prefixed_unit(value)`. @@ -270,7 +270,7 @@ PrefixedUnitPreset( ) ``` -### format_time_delta +### > `format_time_delta` Formats time interval in 4 different variants - 3-char, 4-char, 6-char and 10-char width output. Usage: `format_time_delta(seconds, max_len)`. @@ -821,10 +821,12 @@ You can of course create your own sequences and formats, but with one limitation ### v1.8.0 -- `format_prefixed_unit` extended for working with decimal and binary metric prefixes. -- `format_time_delta` extended with new settings. -- Value rounding transferred from `format_auto_float` to `format_prefixed_unit` -- Utility classes reorganization. +- `format_prefixed_unit` extended for working with decimal and binary metric prefixes; +- `format_time_delta` extended with new settings; +- Value rounding transferred from `format_auto_float` to `format_prefixed_unit`; +- Utility classes reorganization; +- Unit tests output formatting; +- `noop` SGR sequence and `noop` format. ### v1.7.4 diff --git a/dev/runConfigurations/run-debug.run.xml b/dev/runConfigurations/run-debug.run.xml new file mode 100644 index 00000000..dd5c45ec --- /dev/null +++ b/dev/runConfigurations/run-debug.run.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/dev/runConfigurations/run.run.xml b/dev/runConfigurations/run.run.xml new file mode 100644 index 00000000..a4cc8065 --- /dev/null +++ b/dev/runConfigurations/run.run.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/pytermor/fmt.py b/pytermor/fmt.py index ee9efb5e..01a192ec 100644 --- a/pytermor/fmt.py +++ b/pytermor/fmt.py @@ -6,7 +6,7 @@ from typing import Any -from . import build, sgr, SequenceSGR +from . import build, sgr, seq, SequenceSGR from .registry import sgr_parity_registry @@ -43,7 +43,7 @@ def closing_seq(self) -> SequenceSGR: def _opt_arg(self, arg: SequenceSGR | None) -> SequenceSGR: if not arg: - return SequenceSGR() + return seq.NOOP return arg def __call__(self, text: Any = None) -> str: @@ -52,9 +52,7 @@ def __call__(self, text: Any = None) -> str: def __eq__(self, other: Format) -> bool: if not isinstance(other, Format): return False - - return self._opening_seq == other._opening_seq \ - and self._closing_seq == other._closing_seq + return self._opening_seq == other._opening_seq and self._closing_seq == other._closing_seq def __repr__(self): return super().__repr__() + '[{!r}, {!r}]'.format(self._opening_seq, self._closing_seq) @@ -66,6 +64,15 @@ def autof(*args: str | int | SequenceSGR) -> Format: return Format(opening_seq, closing_seq) +noop = autof() +"""Special instance in cases where you *have to* select one or +another Format, but do not want anything to be actually printed. + +- ``noop(string)`` or ``noop.wrap(string)`` returns ``string`` without any modifications; +- ``noop.opening_str`` and ``noop.closing_str`` are empty strings; +- ``noop.opening_seq`` and ``noop.closing_seq`` both returns ``seq.NOOP``. +""" + bold = autof(sgr.BOLD) dim = autof(sgr.DIM) italic = autof(sgr.ITALIC) diff --git a/pytermor/numf/prefixed_unit.py b/pytermor/numf/prefixed_unit.py index ddbd4973..5cd484ce 100644 --- a/pytermor/numf/prefixed_unit.py +++ b/pytermor/numf/prefixed_unit.py @@ -25,16 +25,8 @@ class PrefixedUnitPreset: PREFIXES_SI = ['y', 'z', 'a', 'f', 'p', 'n', 'μ', 'm', None, 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'] PREFIX_ZERO_SI = 8 -""" -Suitable for formatting any SI unit with values -from approximately 10^-27 to 10^27 . - -*max_value_len* must be at least `4`, because it's a -minimum requirement for displaying values from 999 to -999. -Next number to 999 is 1000, which will be displayed as 1k. -""" PRESET_SI_METRIC = PrefixedUnitPreset( - max_value_len=5, + max_value_len=4, integer_input=False, unit='m', unit_separator=' ', @@ -42,6 +34,15 @@ class PrefixedUnitPreset: prefixes=PREFIXES_SI, prefix_zero_idx=PREFIX_ZERO_SI, ) +""" +Suitable for formatting any SI unit with values +from approximately `10^-27` to `10^27`. + +``max_value_len`` must be at least **4**, because it's a +minimum requirement for displaying values from `999` to `-999`. +Next number to `999` is `1000`, which will be displayed as ``1k``. +""" + PRESET_SI_BINARY = PrefixedUnitPreset( max_value_len=5, integer_input=True, @@ -51,6 +52,19 @@ class PrefixedUnitPreset: prefixes=PREFIXES_SI, prefix_zero_idx=PREFIX_ZERO_SI, ) +""" +Similar to ``PRESET_SI_METRIC``, but this preset differs in + one aspect. Given a variable with default value = `995`, printing +it's value out using this preset results in ``995 b``. After +increasing it by `20` we'll have `1015`, but it's still not enough +to become a kilobyte -- so displayed value will be ``1015 b``. Only +after one more increasing (at *1024* and more) the value will be +in a form of ``1.00 kb``. + +So, in this case``max_value_len`` must be at least **5** (not 4), +because it's a minimum requirement for displaying values from `1023` +to `-1023`. +""" def format_prefixed_unit(value: float, preset: PrefixedUnitPreset = None) -> str: diff --git a/pytermor/seq.py b/pytermor/seq.py index 33295dec..a56a748e 100644 --- a/pytermor/seq.py +++ b/pytermor/seq.py @@ -32,6 +32,10 @@ def __repr__(self): class AbstractSequenceCSI(AbstractSequence, metaclass=ABCMeta): + """ + Class representing CSI-type ANSI escape sequence. All subtypes of this + sequence have something in common - they all start with ``\\e[``. + """ CONTROL_CHARACTER = '\033' INTRODUCER = '[' SEPARATOR = ';' @@ -44,10 +48,14 @@ def __str__(self) -> str: class SequenceSGR(AbstractSequenceCSI, metaclass=ABCMeta): + """ + Class representing SGR-type ANSI escape sequence with varying amount of parameters. + Addition of one SGR sequence to another is supported. + """ TERMINATOR = 'm' def print(self) -> str: - if len(self._params) == 0: + if len(self._params) == 0: # noop return '' params = self._params @@ -124,6 +132,15 @@ def _validate_extended_color(value: int): raise ValueError(f'Invalid color value: {value}; valid values are 0-255 inclusive') +NOOP = SequenceSGR() +""" +Special instance in cases where you *have to* select one or +another SGR, but do not want anything to be actually printed. + +- ``NOOP.print()`` returns empty string. +- ``NOOP.params()`` returns empty list. +""" + RESET = SequenceSGR(0) # 0 # attributes diff --git a/pytermor/sgr.py b/pytermor/sgr.py index e41f3f0b..9b3baa51 100644 --- a/pytermor/sgr.py +++ b/pytermor/sgr.py @@ -2,6 +2,10 @@ # pytermor [ANSI formatted terminal output toolset] # (C) 2022 A. Shavykin <0.delameter@gmail.com> # ----------------------------------------------------------------------------- +""" +SGR parameter integer codes. +""" + RESET = 0 # attributes diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..d2a3afae --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +blessings~=1.7 +Pygments~=2.12.0 \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py index f028b28c..1c68cb63 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,6 +1,37 @@ -from .common import * +# ----------------------------------------------------------------------------- +# pytermor [ANSI formatted terminal output toolset] +# (C) 2022 A. Shavykin <0.delameter@gmail.com> +# ----------------------------------------------------------------------------- +from pytermor import fmt, Format, autof, seq __all__ = [ - 'is_verbose_mode', - 'print_verbose', + 'VERBOSITY_LEVEL', + 'verb_print', + 'verb_print_info', + 'verb_print_header', + 'verb_print_subtests', ] + +VERBOSITY_LEVEL = 1 + + +def verb_print(msg: str, _fmt: Format = fmt.noop, **argv): + print(_fmt(msg or ''), flush=True, **argv) + + +def verb_print_header(msg: str = ''): + if VERBOSITY_LEVEL <= 2: + return + verb_print(f'{"":8s}{msg}', autof(seq.HI_CYAN)) + + +def verb_print_info(msg: str = ''): + if VERBOSITY_LEVEL <= 2: + return + verb_print(f'{"":12s}{msg}', fmt.cyan) + + +def verb_print_subtests(count: int): + if VERBOSITY_LEVEL <= 1: + return + verb_print(f'({count:d} subtests)', fmt.green, end=' ') diff --git a/tests/common.py b/tests/common.py deleted file mode 100644 index ea7b6534..00000000 --- a/tests/common.py +++ /dev/null @@ -1,13 +0,0 @@ -import sys - -from pytermor import fmt - - -def is_verbose_mode() -> bool: - return any([opt in sys.argv for opt in ['-v', '--verbose']]) - - -def print_verbose(value='', **argv): - if not is_verbose_mode(): - return - print(fmt.cyan(value or ""), flush=True, **argv) diff --git a/tests/numf/test_auto_float.py b/tests/numf/test_auto_float.py index f957e6e4..4d64a851 100644 --- a/tests/numf/test_auto_float.py +++ b/tests/numf/test_auto_float.py @@ -5,7 +5,7 @@ import unittest from pytermor import format_auto_float -from tests import print_verbose +from tests import verb_print_info, verb_print_subtests class TestAutoFloat(unittest.TestCase): @@ -28,8 +28,13 @@ class TestAutoFloat(unittest.TestCase): ] def test_output_has_expected_format(self): + verb_print_info() + for idx, (expected_output, args) in enumerate(self.expected_format_dataset): - with self.subTest(msg=f'Testing #{idx} "{args}" -> max length {expected_output}'): + subtest_msg = f'autofloat/match #{idx}: >{args[1]}< "{args[0]:.2e}" -> "{expected_output}"' + + with self.subTest(msg=subtest_msg): actual_output = format_auto_float(*args) - print_verbose(actual_output, end=', ') - self.assertEqual(expected_output, actual_output) \ No newline at end of file + verb_print_info(subtest_msg + f' => "{actual_output}"') + self.assertEqual(expected_output, actual_output) + verb_print_subtests(len(self.expected_format_dataset)) diff --git a/tests/numf/test_prefixed_unit.py b/tests/numf/test_prefixed_unit.py index 8e2031fe..052a9bb5 100644 --- a/tests/numf/test_prefixed_unit.py +++ b/tests/numf/test_prefixed_unit.py @@ -5,53 +5,58 @@ import unittest from pytermor import format_prefixed_unit, PRESET_SI_BINARY, PRESET_SI_METRIC, PrefixedUnitPreset -from tests import print_verbose +from tests import verb_print_info, verb_print_header, verb_print_subtests class TestPrefixedUnit(unittest.TestCase): - expected_format_high_dataset = [ - ['-142 Tb', -156530231500223], ['-13.5 Gb', -14530231500], - ['-2.33 Gb', -2501234567], ['-668 Mb', -700500000], - ['-41.1 Mb', -43106100], ['-1.20 Mb', -1257800], - ['-130 kb', -133300], ['-44.1 kb', -45200], - ['-14.6 kb', -15000], ['-6.08 kb', -6230], - ['-1.05 kb', -1080], ['-1.00 kb', -1024], - ['-1010 b', -1010], ['-631 b', -631], - ['-180 b', -180], ['-43 b', -43], - ['-10 b', -10], ['-1 b', -1], - ['0 b', 0], - ['1 b', 1], ['10 b', 10], - ['43 b', 43], ['180 b', 180], - ['631 b', 631], ['1010 b', 1010], - ['1.00 kb', 1024], ['1.05 kb', 1080], - ['6.08 kb', 6230], ['14.65 kb', 15000], - ['44.14 kb', 45200], ['130.2 kb', 133300], - ['1.20 Mb', 1257800], ['41.11 Mb', 43106100], - ['668.0 Mb', 700500000], ['2.33 Gb', 2501234567], - ['13.53 Gb', 14530231500], ['142.4 Tb', 156530231500223], + expected_format_dataset = [ + [PRESET_SI_BINARY, [ + ['-142 Tb', -156530231500223], ['-13.5 Gb', -14530231500], + ['-2.33 Gb', -2501234567], ['-668 Mb', -700500000], + ['-41.1 Mb', -43106100], ['-1.20 Mb', -1257800], + ['-130 kb', -133300], ['-44.1 kb', -45200], + ['-14.6 kb', -15000], ['-6.08 kb', -6230], + ['-1.05 kb', -1080], ['-1.00 kb', -1024], + ['-1010 b', -1010], ['-631 b', -631], + ['-180 b', -180], ['-43 b', -43], + ['-10 b', -10], ['-1 b', -1], + ['0 b', 0], + ['1 b', 1], ['10 b', 10], + ['43 b', 43], ['180 b', 180], + ['631 b', 631], ['1010 b', 1010], + ['1.00 kb', 1024], ['1.05 kb', 1080], + ['6.08 kb', 6230], ['14.65 kb', 15000], + ['44.14 kb', 45200], ['130.2 kb', 133300], + ['1.20 Mb', 1257800], ['41.11 Mb', 43106100], + ['668.0 Mb', 700500000], ['2.33 Gb', 2501234567], + ['13.53 Gb', 14530231500], ['142.4 Tb', 156530231500223], + ]], [PRESET_SI_METRIC, [ + ['1.23 m', 1.23456789], + ['0.12 m', 0.123456789], ['0.01 m', 1.23456789e-2], ['0.00 m', 1.23456789e-3], + ['0.12 mm', 1.23456789e-4], ['0.01 mm', 1.23456789e-5], ['0.00 mm', 1.23456789e-6], + ['0.12 μm', 1.23456789e-7], ['0.01 μm', 1.23456789e-8], ['0.00 μm', 1.23456789e-9], + ['0.12 nm', 1.23456789e-10], ['0.01 nm', 1.23456789e-11], ['0.00 nm', 1.23456789e-12], + ['0.12 pm', 1.23456789e-13], ['0.01 pm', 1.23456789e-14], ['0.00 pm', 1.23456789e-15], + ['0.12 fm', 1.23456789e-16], ['0.01 fm', 1.23456789e-17], ['0.00 fm', 1.23456789e-18], + ]] ] - def test_output_high_has_expected_format(self): - for idx, (expected_output, input_num) in enumerate(self.expected_format_high_dataset): - with self.subTest(msg=f'Testing #{idx} {input_num} -> "{expected_output}"'): - actual_output = format_prefixed_unit(input_num, PRESET_SI_BINARY) - print_verbose(actual_output, end=', ') - self.assertEqual(expected_output, actual_output) + def test_output_has_expected_format(self): + verb_print_info() + subtest_count = 0 - # @TODO incomplete - expected_format_low_dataset = [ - ['1.00 m', 1], - ['0.10 m', 0.1], - ['0.01 m', 1e-2], # @FIXME 10.0mm - ['1.00 mm', 1e-3], - ] + for preset_idx, (preset, preset_input) in enumerate(self.expected_format_dataset): + for input_idx, (expected_output, input_num) in enumerate(preset_input): + subtest_msg = f'prefixed/match P{preset_idx} #{input_idx}: "{input_num:.2e}" -> "{expected_output}"' + + with self.subTest(msg=subtest_msg): + actual_output = format_prefixed_unit(input_num, preset) + verb_print_info(subtest_msg + f' => "{actual_output}"') + subtest_count += 1 + self.assertEqual(expected_output, actual_output) + verb_print_subtests(subtest_count) - def test_output_low_has_expected_format(self): - for idx, (expected_output, input_num) in enumerate(self.expected_format_low_dataset): - with self.subTest(msg=f'Testing #{idx} {input_num} -> "{expected_output}"'): - actual_output = format_prefixed_unit(input_num, PRESET_SI_METRIC) - print_verbose(actual_output, end=', ') - self.assertEqual(expected_output, actual_output) + """ ----------------------------------------------------------------------------------------------------------- """ req_len_dataset = [ [8, PRESET_SI_METRIC], @@ -69,15 +74,23 @@ def test_output_low_has_expected_format(self): unit=None, unit_separator=None, )], ] - req_len_input_num_list = [.076 * pow(11, x) * (-1 * (x % 2)) for x in range(-20, 20)] + req_len_input_num_list = [.076 * pow(11, x) * (1 - 2 * (x % 2)) for x in range(-20, 20)] def test_output_fits_in_required_length(self): + verb_print_info() + subtest_count = 0 + for preset_idx, (expected_max_len, preset) in enumerate(self.req_len_dataset): - print_verbose(f'\nLEN={expected_max_len:d}', end=': ') + verb_print_header(f'expected_max_len={expected_max_len:d}: ') + for input_idx, input_num in enumerate(self.req_len_input_num_list): - with self.subTest(msg=f'Testing P{preset_idx} #{input_idx} "{input_num}" -> max length {expected_max_len}'): + subtest_msg = f'prefixed/len P{preset_idx} #{input_idx} "{input_num:.2e}" -> (len {expected_max_len})' + + with self.subTest(msg=subtest_msg): actual_output = format_prefixed_unit(input_num, preset) - print_verbose(actual_output, end=', ') + verb_print_info(subtest_msg + f' => (len {actual_output}) "{actual_output}"') + subtest_count += 1 self.assertGreaterEqual(expected_max_len, len(actual_output), - f'Actual output ("{actual_output}") exceeds maximum') \ No newline at end of file + f'Actual output ("{actual_output}") exceeds maximum') + verb_print_subtests(subtest_count) diff --git a/tests/numf/test_time_delta.py b/tests/numf/test_time_delta.py index 59077159..438addbb 100644 --- a/tests/numf/test_time_delta.py +++ b/tests/numf/test_time_delta.py @@ -6,7 +6,7 @@ from datetime import timedelta from pytermor import format_time_delta -from tests import print_verbose +from tests import verb_print_info, verb_print_header, verb_print_subtests class TestTimeDelta(unittest.TestCase): @@ -76,30 +76,45 @@ class TestTimeDelta(unittest.TestCase): ] def test_output_has_expected_format(self): + verb_print_info() + for idx, (expected_output, input_arg) in enumerate(self.expected_format_dataset): - with self.subTest(msg=f'Test #{idx}: "{input_arg}" -> "{expected_output}"'): - if type(input_arg) is not timedelta: - input_arg = timedelta(days=input_arg) + subtest_msg = f'tdelta/match #{idx}: "{input_arg}" -> "{expected_output}"' + with self.subTest(msg=subtest_msg): actual_output = format_time_delta(input_arg.total_seconds(), self.expected_format_max_len) - print_verbose(actual_output, end=', ') + verb_print_info(subtest_msg + f' => "{actual_output}"') self.assertEqual(expected_output, actual_output) + verb_print_subtests(len(self.expected_format_dataset)) + + """ ----------------------------------------------------------------------------------------------------------- """ + req_len_expected_len_list = [3, 4, 6, 10, 9, 1000] req_len_input_delta_list = [el[1] for el in expected_format_dataset] def test_output_fits_in_required_length(self): + verb_print_info() + for idx, expected_max_len in enumerate(self.req_len_expected_len_list): - print_verbose(f'\nLEN={expected_max_len:d}', end=': ') - for input_td in self.req_len_input_delta_list: - with self.subTest(msg=f'Testing #{idx} "{input_td}" -> max length {expected_max_len}'): + verb_print_header(f'expected_max_len={expected_max_len:d}:') + + for input_idx, input_td in enumerate(self.req_len_input_delta_list): + subtest_msg = f'tdelta/len #{input_idx}: "{input_td}" -> (len {expected_max_len})' + + with self.subTest(msg=subtest_msg): actual_output = format_time_delta(input_td.total_seconds(), expected_max_len) - print_verbose(actual_output, end=', ') - self.assertGreaterEqual(expected_max_len, len(actual_output)) + verb_print_info(subtest_msg + f' => (len {len(actual_output)}) "{actual_output}"') + + self.assertGreaterEqual(expected_max_len, + len(actual_output), + f'Actual output ("{actual_output}") exceeds maximum') + + """ ----------------------------------------------------------------------------------------------------------- """ invalid_len_list = [-5, 0, 1, 2] def test_invalid_max_length_fails(self): for invalid_max_len in self.invalid_len_list: - with self.subTest(msg=f'Testing invalid max length {invalid_max_len}'): - self.assertRaises(ValueError, lambda: format_time_delta(100, invalid_max_len)) \ No newline at end of file + with self.subTest(msg=f'invalid max length {invalid_max_len}'): + self.assertRaises(ValueError, lambda: format_time_delta(100, invalid_max_len)) diff --git a/tests/run.py b/tests/run.py new file mode 100644 index 00000000..aaafbe34 --- /dev/null +++ b/tests/run.py @@ -0,0 +1,66 @@ +# ----------------------------------------------------------------------------- +# pytermor [ANSI formatted terminal output toolset] +# (C) 2022 A. Shavykin <0.delameter@gmail.com> +# ----------------------------------------------------------------------------- +import sys +import unittest + +from blessings import Terminal +from colour_runner.result import ColourTextTestResult +from colour_runner.runner import ColourTextTestRunner +from pygments.formatters.terminal256 import Terminal256Formatter +from pygments.style import Style +from pygments.token import Name, Generic, Literal + +from pytermor import autof, seq, fmt +import tests + + +class CustomOutputStyle(Style): + default_style = "" + styles = { + Generic.Traceback: 'ansigray', + Generic.Error: 'ansibrightred bold', + Literal: 'ansiyellow bold', + Name.Builtin: 'bold', + } + # /pygments/lexers/python.py:714 + # /pygments/style.py:14 + + +class CustomColourTextTestResult(ColourTextTestResult): + _terminal = Terminal() + formatter = Terminal256Formatter(style=CustomOutputStyle) + colours = { + None: str, + 'error': autof(seq.RED), + 'expected': autof(seq.YELLOW), + 'fail': autof(seq.HI_RED + seq.BOLD), + 'fail_label': autof(seq.RED + seq.INVERSED), + 'skip': str, + 'success': autof(seq.GREEN), + 'title': autof(seq.YELLOW), + 'unexpected': autof(seq.RED), + } + separator1 = ('=' * 70) + separator2 = fmt.gray('-' * 70) + + def addFailure(self, test, err): + super(ColourTextTestResult, self).addFailure(test, err) + self.printResult('F', 'FAIL', 'fail_label') + + +if __name__ == '__main__': + while len(sys.argv) > 0: + arg = sys.argv.pop(0) + if arg in ['-v', '-verbose']: + tests.VERBOSITY_LEVEL += 1 + elif arg == '-vv': + tests.VERBOSITY_LEVEL += 2 + + loader = unittest.TestLoader() + start_dir = 'tests' + suite = loader.discover(start_dir) + runner = ColourTextTestRunner(resultclass=CustomColourTextTestResult) + runner.verbosity = tests.VERBOSITY_LEVEL + runner.run(suite) diff --git a/tests/test_fmt.py b/tests/test_fmt.py index 41485703..359d2e2e 100644 --- a/tests/test_fmt.py +++ b/tests/test_fmt.py @@ -45,7 +45,7 @@ def test_format_is_not_equal_to_sgr(self): def test_empty_are_equal(self): f1 = Format(SequenceSGR()) - f2 = Format(SequenceSGR()) + f2 = Format(seq.NOOP) self.assertEqual(f1, f2) @@ -83,7 +83,7 @@ def test_autof_empty_sgr(self): f = autof(SequenceSGR()) self.assertEqual(f.opening_seq, SequenceSGR()) - self.assertEqual(f.closing_seq, SequenceSGR()) + self.assertEqual(f.closing_seq, seq.NOOP) def test_autof_multiple_with_empty_sgr(self): f = autof(seq.BOLD, SequenceSGR(), seq.RED) diff --git a/tests/test_seq.py b/tests/test_seq.py index 3c0e0c16..4174df4b 100644 --- a/tests/test_seq.py +++ b/tests/test_seq.py @@ -12,7 +12,7 @@ def test_regular_is_equal_to_regular(self): self.assertEqual(SequenceSGR(1, 31, 42), SequenceSGR(1, 31, 42)) def test_empty_is_equal_to_empty(self): - self.assertEqual(SequenceSGR(), SequenceSGR()) + self.assertEqual(seq.NOOP, SequenceSGR()) def test_regular_is_not_equal_to_regular(self): self.assertNotEqual(SequenceSGR(2, 31, 42), SequenceSGR(1, 31, 42)) @@ -24,7 +24,7 @@ def test_empty_is_not_equal_to_reset(self): self.assertNotEqual(SequenceSGR(), SequenceSGR(0)) def test_reset_is_not_equal_to_empty(self): - self.assertNotEqual(SequenceSGR(), SequenceSGR(0)) + self.assertNotEqual(seq.NOOP, SequenceSGR(0)) class TestAddition(unittest.TestCase): @@ -32,13 +32,13 @@ def test_addition_of_regular_to_regular(self): self.assertEqual(SequenceSGR(1) + SequenceSGR(3), SequenceSGR(1, 3)) def test_addition_of_regular_to_empty(self): - self.assertEqual(SequenceSGR(41) + SequenceSGR(), SequenceSGR(41)) + self.assertEqual(SequenceSGR(41) + seq.NOOP, SequenceSGR(41)) def test_addition_of_empty_to_regular(self): self.assertEqual(SequenceSGR() + SequenceSGR(44), SequenceSGR(44)) def test_addition_of_empty_to_empty(self): - self.assertEqual(SequenceSGR() + SequenceSGR(), SequenceSGR()) + self.assertEqual(SequenceSGR() + seq.NOOP, SequenceSGR()) def test_addition_of_empty_to_reset(self): self.assertEqual(SequenceSGR() + SequenceSGR(0), SequenceSGR(0)) @@ -75,7 +75,7 @@ def test_build_mixed_args_invalid(self): def test_build_empty_arg(self): s = build(SequenceSGR()) - self.assertEqual(s, SequenceSGR()) + self.assertEqual(s, seq.NOOP) def test_build_mixed_with_empty_arg(self): s = build(3, SequenceSGR()) From f5e69fa6786ad4f55751b0397c514cab638f00be Mon Sep 17 00:00:00 2001 From: delameter Date: Fri, 20 May 2022 06:04:30 +0300 Subject: [PATCH 4/4] - Max decimal points for `auto_float` extended from (2) to (max-2). --- Makefile | 2 +- README.md | 56 +++++++++++++---------- dev/readme/README.tpl.md | 24 ++++++---- dev/runConfigurations/run-debug.run.xml | 2 +- dev/runConfigurations/run-verbose.run.xml | 24 ++++++++++ pytermor/numf/auto_float.py | 4 +- pytermor/numf/prefixed_unit.py | 16 ++++++- tests/numf/test_auto_float.py | 20 ++++---- tests/numf/test_prefixed_unit.py | 24 ++++++---- 9 files changed, 115 insertions(+), 57 deletions(-) create mode 100644 dev/runConfigurations/run-verbose.run.xml diff --git a/Makefile b/Makefile index 74222bc0..83e3c2e8 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ GREEN := $(shell tput -Txterm setaf 2) YELLOW := $(shell tput -Txterm setaf 3) RESET := $(shell tput -Txterm sgr0) -help: +help: ## Show this help @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v @fgrep | sed -Ee 's/^##\s*([^#]+)#*\s*(.*)/${YELLOW}\1${RESET}#\2/' -e 's/(.+):(#|\s)+(.+)/## ${GREEN}\1${RESET}#\3/' | column -t -s '#' cleanup: diff --git a/README.md b/README.md index 488009b3..1c2f6546 100644 --- a/README.md +++ b/README.md @@ -146,9 +146,9 @@ print(orig_text, '\n', updated_text) As you can see, the update went well — we kept all the previously applied formatting. Of course, this method cannot be 100% applicable — for example, imagine that original text was colored blue. After the update "string" word won't be blue anymore, as we used `COLOR_OFF` escape sequence to neutralize our own yellow color. But it still can be helpful for a majority of cases (especially when text is generated and formatted by the same program and in one go). -## API: module +## API: pytermor -### autof +### > `autof` Signature: `autof(*params str|int|SequenceSGR) -> Format` @@ -161,7 +161,7 @@ Each sequence param can be specified as: - integer param value - existing _SequenceSGR_ instance (params will be extracted) -### build +### > `build` Signature: `build(*params str|int|SequenceSGR) -> SequenceSGR` @@ -169,13 +169,13 @@ Create new _SequenceSGR_ with specified params. Resulting sequence params order _SequenceSGR_ with zero params was specifically implemented to translate into empty string and not into `\e[m`, which wolud make sense, but also would be very entangling, as it's equivavlent of `\e[0m` — **hard reset** sequence. -### build_c256 +### > `build_c256` Signature:`build_c256(color: int, bg: bool = False) -> SequenceSGR` Create new _SequenceSGR_ that sets foreground color or background color, depending on `bg` value, in 256-color mode. Valid values for `color` are [0; 255], see more at [↗ xterm-256 colors](https://www.ditig.com/256-colors-cheat-sheet) page. -### build_rgb +### > `build_rgb` Signature:`build_rgb(r: int, g: int, b: int, bg: bool = False) -> SequenceSGR` @@ -184,7 +184,7 @@ Create new _SequenceSGR_ that sets foreground color or background color, dependi ## API: SGR sequences -Class representing SGR-mode ANSI escape sequence with varying amount of parameters. +Class representing SGR-type ANSI escape sequence with varying amount of parameters.
Details (click) @@ -293,7 +293,7 @@ print(formatted, '\n', replaced) ### Usage with helper -Helper function `apply_filters` accepts both `StringFilter` implementation instances and types, but latter is not configurable and will be invoked using default settings. +Helper function `apply_filters` accepts both `StringFilter` instances and types, but latter is not configurable and will be invoked using default settings. ```python3 from pytermor import apply_filters, ReplaceNonAsciiBytes @@ -311,9 +311,9 @@ print(ascii_and_binary, '\n', result) Set of methods to make working with SGR sequences a bit easier. -- `ljust_fmtd` SGR-formatting-aware implementation of str.ljust() -- `rjust_fmtd` same, but for _str.rjust()_ -- `center_fmtd` same, but for _str.center()_ +- `ljust_fmtd()` SGR-formatting-aware implementation of str.ljust() +- `rjust_fmtd()` same, but for _str.rjust()_ +- `center_fmtd()` same, but for _str.center()_ ## API: numf.* @@ -323,7 +323,7 @@ Set of methods to make working with SGR sequences a bit easier.
Details (click) -### format_auto_float +### > `format_auto_float` Dynamically adjust decimal digit amount to fill the output string up with significant digits as much as possible. Universal solution for situations when you don't know exaclty what values will be displayed, but have fixed output width. Invocation: `format_auto_float(value, 4)`. @@ -335,7 +335,7 @@ Dynamically adjust decimal digit amount to fill the output string up with signif | **1.56** | `"1.56"` | -### format_prefixed_unit +### > `format_prefixed_unit` Similar to previous method, but this one also supports metric prefixes and is highly customizable. Invocation: `format_prefixed_unit(value)`. @@ -343,11 +343,7 @@ Similar to previous method, but this one also supports metric prefixes and is hi | :------: | :--------: | :--------: | :--------: | :--------: | :--------: | :--------: | :--------: | | result | 631 b | 1.05 kb | 44.14 kb | 1.20 Mb | 41.11 Mb | 668.0 Mb | 2.33 Gb | -| value | **1** | **0.1** | ... | -| :------: | :--------: | :--------: | :---: | -| result | 1.00 m | 0.10 m | @TODO | - -Settings example: +Settings: ```python PrefixedUnitPreset( max_value_len=5, integer_input=True, @@ -356,8 +352,17 @@ PrefixedUnitPreset( prefixes=[None, 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'], prefix_zero_idx=0, ) +``` + +Example #2 illustrating small numbers: + +| value | **-1.2345×10⁻¹¹** | **1.2345×10⁻⁸** | **1.2345×10⁻⁴** | **0.01234** | **1.23456** | **123.456** | **−12 345** | +| :------: | :--------: | :--------: | :---: | :---: | :---: | :---: | :---: | +| result | -0.012nm | 0.0123μm | 0.1235mm | 0.0123m | 1.2346m | 123.46m | -12.35km + +```python PrefixedUnitPreset( - max_value_len=7, integer_input=False, + max_value_len=6, integer_input=False, unit='m', unit_separator='', mcoef=1000.0, prefixes=['y', 'z', 'a', 'f', 'p', 'n', 'μ', 'm', None], @@ -365,7 +370,7 @@ PrefixedUnitPreset( ) ``` -### format_time_delta +### > `format_time_delta` Formats time interval in 4 different variants - 3-char, 4-char, 6-char and 10-char width output. Usage: `format_time_delta(seconds, max_len)`. @@ -916,11 +921,14 @@ You can of course create your own sequences and formats, but with one limitation ### v1.8.0 -- `format_prefixed_unit` extended for working with decimal and binary metric prefixes. -- `format_time_delta` extended with new settings. -- Value rounding transferred from `format_auto_float` to `format_prefixed_unit` -- Utility classes reorganization. - +- `format_prefixed_unit` extended for working with decimal and binary metric prefixes; +- `format_time_delta` extended with new settings; +- Value rounding transferred from `format_auto_float` to `format_prefixed_unit`; +- Utility classes reorganization; +- Unit tests output formatting; +- `noop` SGR sequence and `noop` format; +- Max decimal points for `auto_float` extended from (2) to (max-2). +- ### v1.7.4 - Added 3 formatters: `fmt_prefixed_unit`, `fmt_time_delta`, `fmt_auto_float`. diff --git a/dev/readme/README.tpl.md b/dev/readme/README.tpl.md index 9b3be447..17f3f12a 100644 --- a/dev/readme/README.tpl.md +++ b/dev/readme/README.tpl.md @@ -204,7 +204,7 @@ Can be applied using `.apply()` method or with direct call. ### Usage with helper -Helper function `apply_filters` accepts both `StringFilter` implementation instances and types, but latter is not configurable and will be invoked using default settings. +Helper function `apply_filters` accepts both `StringFilter` instances and types, but latter is not configurable and will be invoked using default settings. @{dev/readme/examples/example11.py} > ![image](https://user-images.githubusercontent.com/50381946/161387889-a1920f13-f5fc-4d10-b535-93f1a1b1aa5c.png) @@ -248,11 +248,7 @@ Similar to previous method, but this one also supports metric prefixes and is hi | :------: | :--------: | :--------: | :--------: | :--------: | :--------: | :--------: | :--------: | | result | 631 b | 1.05 kb | 44.14 kb | 1.20 Mb | 41.11 Mb | 668.0 Mb | 2.33 Gb | -| value | **1** | **0.1** | ... | -| :------: | :--------: | :--------: | :---: | -| result | 1.00 m | 0.10 m | @TODO | - -Settings example: +Settings: ```python PrefixedUnitPreset( max_value_len=5, integer_input=True, @@ -261,8 +257,17 @@ PrefixedUnitPreset( prefixes=[None, 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'], prefix_zero_idx=0, ) +``` + +Example #2 illustrating small numbers: + +| value | **-1.2345×10⁻¹¹** | **1.2345×10⁻⁸** | **1.2345×10⁻⁴** | **0.01234** | **1.23456** | **123.456** | **−12 345** | +| :------: | :--------: | :--------: | :---: | :---: | :---: | :---: | :---: | +| result | -0.012nm | 0.0123μm | 0.1235mm | 0.0123m | 1.2346m | 123.46m | -12.35km + +```python PrefixedUnitPreset( - max_value_len=7, integer_input=False, + max_value_len=6, integer_input=False, unit='m', unit_separator='', mcoef=1000.0, prefixes=['y', 'z', 'a', 'f', 'p', 'n', 'μ', 'm', None], @@ -826,8 +831,9 @@ You can of course create your own sequences and formats, but with one limitation - Value rounding transferred from `format_auto_float` to `format_prefixed_unit`; - Utility classes reorganization; - Unit tests output formatting; -- `noop` SGR sequence and `noop` format. - +- `noop` SGR sequence and `noop` format; +- Max decimal points for `auto_float` extended from (2) to (max-2). +- ### v1.7.4 - Added 3 formatters: `fmt_prefixed_unit`, `fmt_time_delta`, `fmt_auto_float`. diff --git a/dev/runConfigurations/run-debug.run.xml b/dev/runConfigurations/run-debug.run.xml index dd5c45ec..167adf29 100644 --- a/dev/runConfigurations/run-debug.run.xml +++ b/dev/runConfigurations/run-debug.run.xml @@ -13,7 +13,7 @@