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/Makefile b/Makefile index 85f84d59..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: @@ -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/README.md b/README.md index 79f4b969..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) @@ -262,7 +262,7 @@ print(msg)
-## API: String filters +## API: strf.StringFilter _StringFilter_ is common string modifier interface with dynamic configuration support. @@ -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 @@ -307,6 +307,98 @@ 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 | + +Settings: +```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, +) +``` + +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=6, 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 +919,16 @@ 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; +- 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 50b94bea..17f3f12a 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) @@ -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. @@ -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) @@ -212,6 +212,98 @@ 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 | + +Settings: +```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, +) +``` + +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=6, 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 +824,16 @@ 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; +- 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/runConfigurations/run-debug.run.xml b/dev/runConfigurations/run-debug.run.xml new file mode 100644 index 00000000..167adf29 --- /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-verbose.run.xml b/dev/runConfigurations/run-verbose.run.xml new file mode 100644 index 00000000..33877794 --- /dev/null +++ b/dev/runConfigurations/run-verbose.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/__init__.py b/pytermor/__init__.py index e095b1d3..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,10 +26,12 @@ 'rjust_fmtd', 'center_fmtd', - 'fmt_prefixed_unit', - 'fmt_time_delta', - 'fmt_auto_float', - 'PrefixedUnitFmtPreset', - 'TimeDeltaFmtPreset', + 'format_auto_float', + 'format_prefixed_unit', + 'PrefixedUnitPreset', + '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/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/__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/numf/auto_float.py b/pytermor/numf/auto_float.py new file mode 100644 index 00000000..45111cb8 --- /dev/null +++ b/pytermor/numf/auto_float.py @@ -0,0 +1,34 @@ +# ----------------------------------------------------------------------------- +# pytermor [ANSI formatted terminal output toolset] +# (C) 2022 A. Shavykin <0.delameter@gmail.com> +# ----------------------------------------------------------------------------- +from math import trunc + + +def format_auto_float(value: float, max_len: int) -> str: + """ + Dynamically adjust decimal digit amount to fill the output string + up with significant digits as much as possible. + + Examples: + - 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) + :return: formatted value + """ + max_decimals_len = max_len - 2 + if value < 0: + max_decimals_len -= 1 # minus sign + 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}' + + return f'{value:{max_len}{dot_str}f}' diff --git a/pytermor/numf/prefixed_unit.py b/pytermor/numf/prefixed_unit.py new file mode 100644 index 00000000..3023e7cd --- /dev/null +++ b/pytermor/numf/prefixed_unit.py @@ -0,0 +1,128 @@ +# ----------------------------------------------------------------------------- +# pytermor [ANSI formatted terminal output toolset] +# (C) 2022 A. Shavykin <0.delameter@gmail.com> +# ----------------------------------------------------------------------------- +from __future__ import annotations + +from dataclasses import dataclass +from math import trunc +from typing import List + +from . import format_auto_float + + +@dataclass +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 + + @property + def max_len(self) -> int: + result = self.max_value_len + result += len(self.unit_separator or '') + result += len(self.unit or '') + result += max([len(p) for p in self.prefixes if p]) + return result + + +PREFIXES_SI = ['y', 'z', 'a', 'f', 'p', 'n', 'μ', 'm', None, 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'] +PREFIX_ZERO_SI = 8 + +PRESET_SI_METRIC = PrefixedUnitPreset( + max_value_len=4, + integer_input=False, + unit='m', + unit_separator=' ', + mcoef=1000.0, + 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``. + +Total maximum length is ``max_value_len + 3 =`` **7** (+3 is from separator, +unit and prefix, assuming all of them have 1-char width). +""" + +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, +) +""" +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`. + +Total maximum length is ``max_value_len + 3 =`` **8** (+3 is from separator, +unit and prefix, assuming all of them have 1-char width). +""" + + +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 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 + :return: formatted value + :rtype: str + """ + if preset is None: + preset = 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 = (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 = format_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 if p]) + \ + (preset.unit or "") diff --git a/pytermor/util/time_delta.py b/pytermor/numf/time_delta.py similarity index 50% rename from pytermor/util/time_delta.py rename to pytermor/numf/time_delta.py index d5774145..8f4cc24a 100644 --- a/pytermor/util/time_delta.py +++ b/pytermor/numf/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,54 +19,70 @@ 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', + ), } -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 @@ -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: 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/pytermor/util/__init__.py b/pytermor/strf/__init__.py similarity index 66% rename from pytermor/util/__init__.py rename to pytermor/strf/__init__.py index b3515a03..2de7411e 100644 --- a/pytermor/util/__init__.py +++ b/pytermor/strf/__init__.py @@ -2,13 +2,9 @@ # 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 * -from .prefixed_unit import * -from .time_delta import * - __all__ = [ 'apply_filters', 'StringFilter', @@ -19,10 +15,4 @@ 'ljust_fmtd', 'rjust_fmtd', 'center_fmtd', - - 'fmt_prefixed_unit', - 'fmt_time_delta', - 'fmt_auto_float', - 'PrefixedUnitFmtPreset', - 'TimeDeltaFmtPreset', ] diff --git a/pytermor/util/fmtd.py b/pytermor/strf/fmtd.py similarity index 64% rename from pytermor/util/fmtd.py rename to pytermor/strf/fmtd.py index 14dfc9fc..c9392a47 100644 --- a/pytermor/util/fmtd.py +++ b/pytermor/strf/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/filter.py b/pytermor/strf/string_filter.py similarity index 100% rename from pytermor/util/filter.py rename to pytermor/strf/string_filter.py diff --git a/pytermor/util/auto_float.py b/pytermor/util/auto_float.py deleted file mode 100644 index e5586a96..00000000 --- a/pytermor/util/auto_float.py +++ /dev/null @@ -1,38 +0,0 @@ -# ----------------------------------------------------------------------------- -# pytermor [ANSI formatted terminal output toolset] -# (C) 2022 A. Shavykin <0.delameter@gmail.com> -# ----------------------------------------------------------------------------- -from math import trunc - - -def fmt_auto_float(value: float, max_len: int, output_int: bool) -> 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 - - :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) - - 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' - - return f'{value:{max_len}{dot_str}f}' diff --git a/pytermor/util/prefixed_unit.py b/pytermor/util/prefixed_unit.py deleted file mode 100644 index 1fd1cfd9..00000000 --- a/pytermor/util/prefixed_unit.py +++ /dev/null @@ -1,75 +0,0 @@ -# ----------------------------------------------------------------------------- -# pytermor [ANSI formatted terminal output toolset] -# (C) 2022 A. Shavykin <0.delameter@gmail.com> -# ----------------------------------------------------------------------------- -from __future__ import annotations - -from dataclasses import dataclass -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: - """ - 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. - - :param value: input value - :param preset: formatter settings - :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 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/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..1c68cb63 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,37 @@ +# ----------------------------------------------------------------------------- +# pytermor [ANSI formatted terminal output toolset] +# (C) 2022 A. Shavykin <0.delameter@gmail.com> +# ----------------------------------------------------------------------------- +from pytermor import fmt, Format, autof, seq + +__all__ = [ + '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/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..492a27ce --- /dev/null +++ b/tests/numf/test_auto_float.py @@ -0,0 +1,40 @@ +# ----------------------------------------------------------------------------- +# 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 verb_print_info, verb_print_subtests + + +class TestAutoFloat(unittest.TestCase): + expected_format_dataset = [ + ['0.0', [0, 3]], + ['6.0', [6, 3]], + ['146', [145.66, 3]], + ['300.0', [300, 5]], + ['30.00', [30, 5]], + ['3.000', [3, 5]], + ['0.300', [.3, 5]], + ['0.030', [.03, 5]], + ['0.003', [.003, 5]], + ['-5.00', [-5, 5]], + [' -512', [-512, 5]], + ['1.2000', [1.2, 6]], + ['123456', [123456, 6]], + ['0.0001', [0.00012, 6]], + ['0.0120', [0.012, 6]], + ] + + def test_output_has_expected_format(self): + verb_print_info() + + for idx, (expected_output, args) in enumerate(self.expected_format_dataset): + 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) + 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 new file mode 100644 index 00000000..b11dc861 --- /dev/null +++ b/tests/numf/test_prefixed_unit.py @@ -0,0 +1,100 @@ +# ----------------------------------------------------------------------------- +# 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 verb_print_info, verb_print_header, verb_print_subtests + + +class TestPrefixedUnit(unittest.TestCase): + 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.000 kb', 1024], ['1.055 kb', 1080], + ['6.084 kb', 6230], ['14.65 kb', 15000], + ['44.14 kb', 45200], ['130.2 kb', 133300], + ['1.200 Mb', 1257800], ['41.11 Mb', 43106100], + ['668.0 Mb', 700500000], ['2.329 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_has_expected_format(self): + verb_print_info() + subtest_count = 0 + + 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) + + """ ----------------------------------------------------------------------------------------------------------- """ + + req_len_dataset = [ + [7, PRESET_SI_METRIC], + [8, PRESET_SI_BINARY], + [5, PrefixedUnitPreset( + max_value_len=4, integer_input=False, mcoef=1000.0, + prefixes=PRESET_SI_METRIC.prefixes, + prefix_zero_idx=PRESET_SI_METRIC.prefix_zero_idx, + unit=None, unit_separator=None, + )], + [10, PrefixedUnitPreset( + max_value_len=9, integer_input=False, 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 - 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): + verb_print_header(f'expected_max_len={expected_max_len}: ') + self.assertEqual(expected_max_len, + preset.max_len, + f'Expected max len {expected_max_len} doesn\'t correspond to preset property ({preset.max_len})' + ) + + for input_idx, input_num in enumerate(self.req_len_input_num_list): + 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) + verb_print_info(subtest_msg + f' => (len {len(actual_output)}) "{actual_output}"') + subtest_count += 1 + self.assertGreaterEqual(expected_max_len, + len(actual_output), + 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 new file mode 100644 index 00000000..438addbb --- /dev/null +++ b/tests/numf/test_time_delta.py @@ -0,0 +1,120 @@ +# ----------------------------------------------------------------------------- +# 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 verb_print_info, verb_print_header, verb_print_subtests + + +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): + verb_print_info() + + for idx, (expected_output, input_arg) in enumerate(self.expected_format_dataset): + 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) + 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): + 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) + 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'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/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_fmt.py b/tests/test_fmt.py index 5e231ac6..359d2e2e 100644 --- a/tests/test_fmt.py +++ b/tests/test_fmt.py @@ -7,7 +7,7 @@ from pytermor import autof, seq, sgr, SequenceSGR, Format -class FormatEqualityTestCase(unittest.TestCase): +class TestEquality(unittest.TestCase): def test_same_are_equal(self): f1 = Format(SequenceSGR(sgr.BOLD), SequenceSGR(sgr.RESET)) f2 = Format(SequenceSGR(sgr.BOLD), SequenceSGR(sgr.RESET)) @@ -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) @@ -56,11 +56,11 @@ def test_empty_is_not_equal_to_reset(self): self.assertNotEqual(f1, f2) -class FormatWrapTestCase(unittest.TestCase): +class TestWrap(unittest.TestCase): pass # @todo -class AutoFormatTestCase(unittest.TestCase): +class TestAutoFormat(unittest.TestCase): def test_autof_single_sgr(self): f = autof(seq.BOLD) @@ -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_registry.py b/tests/test_registry.py index 8f77c600..f2e5af3c 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -8,7 +8,7 @@ from pytermor.registry import sgr_parity_registry -class RegistryTestCase(unittest.TestCase): # @TODO more +class TestRegistry(unittest.TestCase): # @TODO more def test_closing_seq(self): self.assertEqual(sgr_parity_registry.get_closing_seq(seq.BOLD + seq.RED), seq.BOLD_DIM_OFF + seq.COLOR_OFF) diff --git a/tests/test_seq.py b/tests/test_seq.py index 9b2998aa..4174df4b 100644 --- a/tests/test_seq.py +++ b/tests/test_seq.py @@ -7,12 +7,12 @@ from pytermor import seq, sgr, build, build_c256, build_rgb, SequenceSGR -class SequenceEqualityTestCase(unittest.TestCase): +class TestEquality(unittest.TestCase): 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,21 +24,21 @@ 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 SequenceAdditionTestCase(unittest.TestCase): +class TestAddition(unittest.TestCase): 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)) @@ -50,7 +50,7 @@ def test_invalid_type_addition(self): self.assertRaises(TypeError, lambda: SequenceSGR(1) + 2) -class SequenceBuildTestCase(unittest.TestCase): +class TestBuild(unittest.TestCase): def test_build_code_args(self): s = build(1, 31, 43) self.assertEqual(s, SequenceSGR(sgr.BOLD, sgr.RED, sgr.BG_YELLOW)) @@ -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()) 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)