diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index c5e9d20..5c0eddc 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -9,13 +9,13 @@ on: - master - dev - gha - - tidying + - time_str pull_request: branches: - master - dev - gha - - tidying + - time_str jobs: build: @@ -24,7 +24,14 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" steps: - uses: actions/checkout@v4 @@ -62,7 +69,7 @@ jobs: python -m pip install --upgrade "pip<21" setuptools mock - name: Compile library modules run: | - python -m compileall diagnosticism + python -m compileall -x '_fmt_py3' diagnosticism - name: Test with unittest run: | python tests/run_unittest.py diff --git a/CHANGES.md b/CHANGES.md index 5791791..ebc7bf2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,10 @@ # **Diagnosticism.Python** Changes +## 0.16.0 - 27th June 2026 + +* added `nanoseconds_to_string()`; + + ## 0.15.3 - 27th June 2026 * added top-level `__all__` documenting the public API; diff --git a/README.md b/README.md index a529ad1..bc263ec 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,14 @@ # Diagnosticism.Python - -[![License](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) -[![PyPI version](https://badge.fury.io/py/diagnosticism.svg)](https://badge.fury.io/py/diagnosticism) -![versions](https://img.shields.io/pypi/pyversions/diagnosticism.svg) -[![Python package](https://github.com/synesissoftware/Diagnosticism.Python/actions/workflows/python-package.yml/badge.svg)](https://github.com/synesissoftware/Diagnosticism.Python/actions/workflows/python-package.yml) -[![Last Commit](https://img.shields.io/github/last-commit/synesissoftware/Diagnosticism.Python)](https://github.com/synesissoftware/Diagnosticism.Python/commits/master) +Diagnosticism, for Python -Diagnosticism library, for Python +![Language](https://img.shields.io/badge/Python-3776AB?style=flat&logo=python&logoColor=white) +[![License](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) +[![PyPI](https://img.shields.io/pypi/v/diagnosticism.svg)](https://pypi.org/project/diagnosticism/) +[![GitHub release](https://img.shields.io/github/v/release/synesissoftware/Diagnosticism.Python.svg)](https://github.com/synesissoftware/Diagnosticism.Python/releases/latest) +![Python](https://img.shields.io/badge/Python-2.7%20%7C%203.8+-lightgrey) +[![CI](https://github.com/synesissoftware/Diagnosticism.Python/actions/workflows/python-package.yml/badge.svg)](https://github.com/synesissoftware/Diagnosticism.Python/actions/workflows/python-package.yml) +[![PyPI project](https://img.shields.io/badge/documentation-PyPI-lightgrey)](https://pypi.org/project/diagnosticism/) ## Table of Contents @@ -33,11 +32,15 @@ Diagnosticism library, for Python - [Debugging API](#debugging-api) - [Diagnostic Logging API](#diagnostic-logging-api-1) - [Tracing API](#tracing-api) + - [Time formatting API](#time-formatting-api) - [Examples](#examples) - [Project Information](#project-information) - [Where to get help](#where-to-get-help) - [Contribution guidelines](#contribution-guidelines) - [Dependencies](#dependencies) + - [Efferent (fan-out)](#efferent-fan-out) + - [Development Dependencies](#development-dependencies) + - [Afferent (fan-in)](#afferent-fan-in) - [Related projects](#related-projects) - [License](#license) @@ -97,6 +100,7 @@ The public API surface is listed in `diagnosticism.__all__`. * Contingent Reporting * Diagnostic Logging +* Time formatting * Tracing **NOTE**: for the moment, the Diagnostic Logging facilities emit to the standard error stream, via the Contingent Reporting API. In the near future this will be changed to work with more sophisticated logging libraries, including the standard logging facilities and the (as yet to be release) **Pantheios.Python**. @@ -123,9 +127,9 @@ def main(): dg = DOOMGram() - for _ in range(1000): + for _ in range(1_000): - r = random.uniform(1, 1000) + r = random.uniform(1, 1_000) d = r / 1_000_000 @@ -285,6 +289,23 @@ The following functions are defined: | `trace()` | Traces the name and signature of the calling function, including the values of all its arguments. | +#### Time formatting API + +| Function | Purpose | +| -------- | ------- | +| `nanoseconds_to_string()` | Formats a nanosecond count as a compact human-readable duration string, adapting the unit (`ns`, `µs`, `ms`, `s`) and decimal precision to keep roughly three significant digits in the numeric portion. Zero is always `"0s"`. An optional `format_spec` may include `'+'` to cause positive values to include an explicit leading sign. | + +For example: + +```Python +from diagnosticism import nanoseconds_to_string + +nanoseconds_to_string(123_456_789) # '123.4ms' +nanoseconds_to_string( 6_789) # '6.789µs' +nanoseconds_to_string(999_772_000, '+') # '+999.7ms' +``` + + ## Examples Examples are provided in the ```examples``` directory, along with a markdown description for each. A detailed list TOC of them is provided in [EXAMPLES.md](./EXAMPLES.md). @@ -305,6 +326,28 @@ Defect reports, feature requests, and pull requests are welcome on https://githu ### Dependencies +**Diagnosticism.Python** has no (non-development) runtime dependencies. + + +#### Efferent (fan-out) + +Libraries upon which **Diagnosticism.Python** depends: + +None. + + +##### Development Dependencies + +* [**mock**](https://pypi.org/project/mock/) — required for running the unit-test suite on **Python 2.7**; + + +#### Afferent (fan-in) + +Projects that depend on **Diagnosticism.Python**: + +* [**asynkio**](https://github.com/synesissoftware/asynkio/); +* [**libpath.Python**](https://github.com/synesissoftware/libpath.Python/); + ### Related projects diff --git a/diagnosticism/__init__.py b/diagnosticism/__init__.py index 1755607..d65a7db 100644 --- a/diagnosticism/__init__.py +++ b/diagnosticism/__init__.py @@ -9,7 +9,7 @@ __license__ = 'BSD-3-Clause' __maintainer__ = 'Matt Wilson' __status__ = 'Beta' -__version__ = '0.15.3' +__version__ = '0.16.0' import sys @@ -56,6 +56,9 @@ parse_verbosity, severity_to_string, ) +from .time_format import ( + nanoseconds_to_string, +) from .tracing import ( dbg, dbgfl, @@ -116,6 +119,7 @@ 'is_tracing_enabled', 'line', 'log', + 'nanoseconds_to_string', 'parse_verbosity', 'report', 'set_default_trailing_prompt', diff --git a/diagnosticism/time_format/__init__.py b/diagnosticism/time_format/__init__.py new file mode 100644 index 0000000..cf63657 --- /dev/null +++ b/diagnosticism/time_format/__init__.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- + +# ######################################################################## # +# File: time_format/__init__.py +# +# Purpose: Time formatting utilities. +# +# Created: 24th August 2025 +# Updated: 27th June 2026 +# +# Author: Matthew Wilson +# +# Copyright (c) 2025-2026, Matthew Wilson and Synesis Information Systems +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +# ######################################################################## # + + +import sys + +if sys.version_info[0] >= 3: + from ._fmt_py3 import _fmt +else: + from ._fmt_py2 import _fmt + + +_SCALES = ( + 1, + 10, + 100, + 1000, # 1,000 + 10000, # 10,000 + 100000, # 100,000 + 1000000, # 1,000,000 + 10000000, # 10,000,000 + 100000000, # 100,000,000 + 1000000000, # 1,000,000,000 + 10000000000, # 10,000,000,000 + 100000000000, # 100,000,000,000 +) + +_SUFFIXES = ( + 'ns', + 'µs', + 'ms', + 's', +) + + +def _scale_index(n): + """ + Selects an order-of-magnitude band for the given positive nanosecond + count. + """ + + assert n > 0 + assert len(_SCALES) == 12 + + if n >= 100000000000: # 100,000,000,000 + + return (11, _SCALES[11]) + + l = 0 + h = 11 + + count = 0 + + while l <= h: + count += 1 + + assert count < 5, "too many loops while trying to scale %s" % n + + m = (h + l) // 2 + + b = _SCALES[m] + + if n == b: + return (m, b) + + if n < b: + h = m + + continue + + assert n > b + + if n < b * 10: + return (m, b) + + l = m + + return (11, _SCALES[11]) + + +from .nanoseconds import nanoseconds_to_string + + +# ############################## end of file ############################# # diff --git a/diagnosticism/time_format/_fmt_py2.py b/diagnosticism/time_format/_fmt_py2.py new file mode 100644 index 0000000..7b1d8dc --- /dev/null +++ b/diagnosticism/time_format/_fmt_py2.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +# ######################################################################## # +# File: time_format/_fmt_py2.py +# +# Purpose: Python 2.7 implementation of `_fmt()`. +# +# Created: 24th August 2025 +# Updated: 27th June 2026 +# +# Copyright (c) 2026-2027, Matthew Wilson and Synesis Information Systems +# All rights reserved. +# +# ######################################################################## # + + +def _fmt( + sign, + whole, + frac, + suffix, +): + """ + Formats whole and fractional parts into a compact duration string. + """ + + if frac == 0: + return "%s%s%s" % (sign, whole, suffix) + + if whole > 999: + return "%s%s%s" % (sign, whole, suffix) + + if whole > 99: + return "%s%s.%s%s" % (sign, whole, frac, suffix) + + if whole > 9: + return "%s%s.%02d%s" % (sign, whole, frac, suffix) + + return "%s%s.%s%s" % (sign, whole, frac, suffix) + + +# ############################## end of file ############################# # diff --git a/diagnosticism/time_format/_fmt_py3.py b/diagnosticism/time_format/_fmt_py3.py new file mode 100644 index 0000000..158343a --- /dev/null +++ b/diagnosticism/time_format/_fmt_py3.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +# ######################################################################## # +# File: time_format/_fmt_py3.py +# +# Purpose: Python 3 implementation of `_fmt()`. +# +# Created: 24th August 2025 +# Updated: 27th June 2026 +# +# Copyright (c) 2026-2027, Matthew Wilson and Synesis Information Systems +# All rights reserved. +# +# ######################################################################## # + + +def _fmt( + sign, + whole, + frac, + suffix, +): + """ + Formats whole and fractional parts into a compact duration string. + """ + + if frac == 0: + return f"{sign}{whole}{suffix}" + + if whole > 999: + return f"{sign}{whole}{suffix}" + + if whole > 99: + return f"{sign}{whole}.{frac}{suffix}" + + if whole > 9: + return f"{sign}{whole}.{frac:02d}{suffix}" + + return f"{sign}{whole}.{frac}{suffix}" + + +# ############################## end of file ############################# # diff --git a/diagnosticism/time_format/nanoseconds.py b/diagnosticism/time_format/nanoseconds.py new file mode 100644 index 0000000..48a0a3d --- /dev/null +++ b/diagnosticism/time_format/nanoseconds.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- + +# ######################################################################## # +# File: time_format/nanoseconds.py +# +# Purpose: Nanosecond duration formatting. +# +# Created: 24th August 2025 +# Updated: 27th June 2026 +# +# Copyright (c) 2026-2027, Matthew Wilson and Synesis Information Systems +# All rights reserved. +# +# ######################################################################## # + + +from . import ( + _fmt, + _scale_index, + _SUFFIXES, +) + +# NOTE: this work was brought in from **asynkio** at version 0.16 + +def nanoseconds_to_string(nanoseconds, format_spec=''): + """ + Formats a nanosecond count as a compact human-readable duration string. + + The output adapts the unit (``ns``, ``µs``, ``ms``, ``s``) and decimal + precision to keep roughly three significant digits in the numeric + portion. + + Parameters + ---------- + nanoseconds : int + The duration, in nanoseconds. + format_spec : str, optional + Formatting options. The only recognised flag is ``+``, which causes + positive values to include an explicit leading sign. Other + characters are ignored. + + Returns + ------- + str + The formatted duration string. Zero is always ``"0s"`` with no sign. + """ + + v = int(nanoseconds) + + if v < 0: + v = -v + sign = '-' + else: + if '+' in format_spec: + sign = '+' + else: + sign = '' + + if v == 0: + return "0s" + + oom, divisor = _scale_index(v) + + suffix = _SUFFIXES[oom // 3] + + if oom < 3: + return _fmt(sign, v, 0, suffix) + + divisor_0 = divisor // 1000 # 1,000 + + i = oom % 3 + + if i == 0: + divisor_1 = 1000 # 1,000 + elif i == 1: + divisor_1 = 100 + else: + divisor_1 = 10 + + v //= divisor_0 + + whole = v // divisor_1 + frac = v - (whole * divisor_1) + + return _fmt( + sign, + whole, + frac, + suffix, + ) + + +# ############################## end of file ############################# # diff --git a/setup.py b/setup.py index 68caba1..4ecd970 100644 --- a/setup.py +++ b/setup.py @@ -4,8 +4,8 @@ setuptools.setup( name='diagnosticism', - version='0.15.3', python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*, !=3.7.*', + version='0.16.0', author='Matt Wilson', author_email='matthew@synesis.com.au', diff --git a/tests/test_time_format.py b/tests/test_time_format.py new file mode 100644 index 0000000..94c7495 --- /dev/null +++ b/tests/test_time_format.py @@ -0,0 +1,151 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + +# ######################################################################## # +# File: tests/test_time_format.py +# +# Purpose: Unit-tests for `diagnosticism.nanoseconds_to_string()`. +# +# Created: 24th August 2025 +# Updated: 27th June 2026 +# +# Copyright (c) Matthew Wilson, Synesis Information Systems Pty Ltd +# All rights reserved +# +# ######################################################################## # + + +import unittest + +from diagnosticism import nanoseconds_to_string + + +class nanoseconds_to_string_tester(unittest.TestCase): + def test_zero(self): + + self.assertEqual("0s", nanoseconds_to_string(0)) + self.assertEqual("0s", nanoseconds_to_string(0, '+')) + + def test_one_second(self): + + self.assertEqual("1s", nanoseconds_to_string(1000000000)) # 1,000,000,000 + + def test_123_milliseconds(self): + + self.assertEqual("123ms", nanoseconds_to_string(123000000)) # 123,000,000 + + def test_123_456_789_nanoseconds(self): + + self.assertEqual("123.4ms", nanoseconds_to_string(123456789)) # 123,456,789 + + def test_strings(self): + + self.assertEqual("9ns", nanoseconds_to_string(9)) + self.assertEqual("89ns", nanoseconds_to_string(89)) + self.assertEqual("789ns", nanoseconds_to_string(789)) + + self.assertEqual("6.789µs", nanoseconds_to_string(6789)) # 6,789 + self.assertEqual("56.78µs", nanoseconds_to_string(56789)) # 56,789 + self.assertEqual("456.7µs", nanoseconds_to_string(456789)) # 456,789 + + self.assertEqual("3.456ms", nanoseconds_to_string(3456789)) # 3,456,789 + self.assertEqual("23.45ms", nanoseconds_to_string(23456789)) # 23,456,789 + self.assertEqual("123.4ms", nanoseconds_to_string(123456789)) # 123,456,789 + + self.assertEqual("9.123s", nanoseconds_to_string(9123456789)) # 9,123,456,789 + self.assertEqual("89.12s", nanoseconds_to_string(89123456789)) # 89,123,456,789 + self.assertEqual("789.1s", nanoseconds_to_string(789123456789)) # 789,123,456,789 + + self.assertEqual("9ns", nanoseconds_to_string(9)) + self.assertEqual("80ns", nanoseconds_to_string(80)) + self.assertEqual("700ns", nanoseconds_to_string(700)) + + self.assertEqual("6µs", nanoseconds_to_string(6000)) # 6,000 + self.assertEqual("50µs", nanoseconds_to_string(50000)) # 50,000 + self.assertEqual("400µs", nanoseconds_to_string(400000)) # 400,000 + + self.assertEqual("3ms", nanoseconds_to_string(3000000)) # 3,000,000 + self.assertEqual("20ms", nanoseconds_to_string(20000000)) # 20,000,000 + self.assertEqual("100ms", nanoseconds_to_string(100000000)) # 100,000,000 + + self.assertEqual("9s", nanoseconds_to_string(9000000000)) # 9,000,000,000 + self.assertEqual("10s", nanoseconds_to_string(10000000000)) # 10,000,000,000 + self.assertEqual("200s", nanoseconds_to_string(200000000000)) # 200,000,000,000 + self.assertEqual("3000s", nanoseconds_to_string(3000000000000)) # 3,000,000,000,000 + self.assertEqual("40000s", nanoseconds_to_string(40000000000000)) # 40,000,000,000,000 + self.assertEqual("500000s", nanoseconds_to_string(500000000000000)) # 500,000,000,000,000 + self.assertEqual("6000000s", nanoseconds_to_string(6000000000000000)) # 6,000,000,000,000,000 + self.assertEqual("70000000s", nanoseconds_to_string(70000000000000000)) # 70,000,000,000,000,000 + + self.assertEqual("11.11s", nanoseconds_to_string(11111111111)) # 11,111,111,111 + self.assertEqual("222.2s", nanoseconds_to_string(222222222222)) # 222,222,222,222 + self.assertEqual("3333s", nanoseconds_to_string(3333333333333)) # 3,333,333,333,333 + self.assertEqual("44444s", nanoseconds_to_string(44444444444444)) # 44,444,444,444,444 + self.assertEqual("555555s", nanoseconds_to_string(555555555555555)) # 555,555,555,555,555 + self.assertEqual("6666666s", nanoseconds_to_string(6666666666666666)) # 6,666,666,666,666,666 + self.assertEqual("77777777s", nanoseconds_to_string(77777777777777777)) # 77,777,777,777,777,777 + + def test_negative_values_strings(self): + + self.assertEqual("-9ns", nanoseconds_to_string(-9)) + self.assertEqual("-89ns", nanoseconds_to_string(-89)) + self.assertEqual("-789ns", nanoseconds_to_string(-789)) + + self.assertEqual("-6.789µs", nanoseconds_to_string(-6789)) # -6,789 + self.assertEqual("-56.78µs", nanoseconds_to_string(-56789)) # -56,789 + self.assertEqual("-456.7µs", nanoseconds_to_string(-456789)) # -456,789 + + self.assertEqual("-3.456ms", nanoseconds_to_string(-3456789)) # -3,456,789 + self.assertEqual("-23.45ms", nanoseconds_to_string(-23456789)) # -23,456,789 + self.assertEqual("-123.4ms", nanoseconds_to_string(-123456789)) # -123,456,789 + + self.assertEqual("-9.123s", nanoseconds_to_string(-9123456789)) # -9,123,456,789 + + self.assertEqual("-9ns", nanoseconds_to_string(-9)) + self.assertEqual("-80ns", nanoseconds_to_string(-80)) + self.assertEqual("-700ns", nanoseconds_to_string(-700)) + + self.assertEqual("-6µs", nanoseconds_to_string(-6000)) # -6,000 + self.assertEqual("-50µs", nanoseconds_to_string(-50000)) # -50,000 + self.assertEqual("-400µs", nanoseconds_to_string(-400000)) # -400,000 + + self.assertEqual("-3ms", nanoseconds_to_string(-3000000)) # -3,000,000 + self.assertEqual("-20ms", nanoseconds_to_string(-20000000)) # -20,000,000 + self.assertEqual("-100ms", nanoseconds_to_string(-100000000)) # -100,000,000 + + self.assertEqual("-9s", nanoseconds_to_string(-9000000000)) # -9,000,000,000 + self.assertEqual("-10s", nanoseconds_to_string(-10000000000)) # -10,000,000,000 + self.assertEqual("-200s", nanoseconds_to_string(-200000000000)) # -200,000,000,000 + self.assertEqual("-3000s", nanoseconds_to_string(-3000000000000)) # -3,000,000,000,000 + self.assertEqual("-40000s", nanoseconds_to_string(-40000000000000)) # -40,000,000,000,000 + + def test_observed_edge_cases(self): + + self.assertEqual("999.7ms", nanoseconds_to_string(999772000)) # 999,772,000 + self.assertEqual("999.8ms", nanoseconds_to_string(999800000)) # 999,800,000 + self.assertEqual("999.9ms", nanoseconds_to_string(999974000)) # 999,974,000 + + self.assertEqual("-999.7ms", nanoseconds_to_string(-999772000)) # -999,772,000 + self.assertEqual("-999.8ms", nanoseconds_to_string(-999800000)) # -999,800,000 + self.assertEqual("-999.9ms", nanoseconds_to_string(-999974000)) # -999,974,000 + + def test_with_plus_sign(self): + + self.assertEqual("999.7ms", nanoseconds_to_string(999772000)) # 999,772,000 + self.assertEqual("999.8ms", nanoseconds_to_string(999800000)) # 999,800,000 + self.assertEqual("999.9ms", nanoseconds_to_string(999974000)) # 999,974,000 + + self.assertEqual("-999.7ms", nanoseconds_to_string(-999772000)) # -999,772,000 + self.assertEqual("-999.8ms", nanoseconds_to_string(-999800000)) # -999,800,000 + self.assertEqual("-999.9ms", nanoseconds_to_string(-999974000)) # -999,974,000 + + self.assertEqual("999.7ms", nanoseconds_to_string(999772000, "")) # 999,772,000 + self.assertEqual("999.8ms", nanoseconds_to_string(999800000, "")) # 999,800,000 + self.assertEqual("999.9ms", nanoseconds_to_string(999974000, "")) # 999,974,000 + + self.assertEqual("+999.7ms", nanoseconds_to_string(999772000, '+')) # 999,772,000 + self.assertEqual("+999.8ms", nanoseconds_to_string(999800000, '+')) # 999,800,000 + self.assertEqual("+999.9ms", nanoseconds_to_string(999974000, '+')) # 999,974,000 + + +# ############################## end of file ############################# #