From 8113859b1bc2a9a33adc086e5ad01fd3f34414cf Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Fri, 26 Jun 2026 16:19:46 +1000 Subject: [PATCH 01/18] tidying --- CHANGES.md | 5 ++ diagnosticism/__init__.py | 4 +- diagnosticism/contingent_reporting.py | 39 +++++++--- diagnosticism/doomgram.py | 20 +++-- diagnosticism/logging.py | 29 ++++--- diagnosticism/severity.py | 108 ++++++++++++++++---------- diagnosticism/tracing.py | 32 ++++---- diagnosticism/warning.py | 14 +++- setup.py | 2 +- tests/test_conrep.py | 28 ++++--- tests/test_log.py | 58 +++++++------- tests/test_parse_severity.py | 13 ++-- tests/test_trace.py | 4 +- tests/test_warn.py | 23 ++---- 14 files changed, 224 insertions(+), 155 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index f7bb7f0..526a2ea 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,10 @@ # **Diagnosticism.Python** Changes +## 0.15.3 - 26th June 2026 + +* tidying; + + ## 0.15.2 - 27th August 2025 * project boilerplate; diff --git a/diagnosticism/__init__.py b/diagnosticism/__init__.py index 81dad70..482ab93 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.2' +__version__ = '0.15.3' import sys @@ -44,8 +44,8 @@ ) if sys.version_info[:2] >= (3, 9): from .tracing import ( - tracefunc, asynctracefunc, + tracefunc, ) from .warning import warn diff --git a/diagnosticism/contingent_reporting.py b/diagnosticism/contingent_reporting.py index b3af37e..b92a59e 100644 --- a/diagnosticism/contingent_reporting.py +++ b/diagnosticism/contingent_reporting.py @@ -25,8 +25,12 @@ def _supports_ansi_sequences(): _OS_IS_POSIX = _supports_ansi_sequences() -STOCK_TRAILING_PROMPT = 'use --help for usage' -"""The trailing prompt used when trailing_prompt=True is passed to abort() (and family) and no default trailing prompt has been set (via set_default_trailing_prompt())""" +STOCK_TRAILING_PROMPT = "use --help for usage" +""" +The trailing prompt used when trailing_prompt=True is passed to abort() (and +family) and no default trailing prompt has been set (via +set_default_trailing_prompt()). +""" _trailing_prompt = None @@ -47,7 +51,8 @@ def set_default_trailing_prompt(prompt): Notes ----- - This method is NOT threadsafe, so in a multithreaded context the set prompt and/or the return value may not be as expected + This method is NOT threadsafe, so in a multithreaded context the set + prompt and/or the return value may not be as expected. """ global _trailing_prompt @@ -196,15 +201,18 @@ def report( file=None, ): """ - Emits the given message (and optional prefix) on the contingent report stream. + Emits the given message (and optional prefix) on the contingent report + stream. Parameters ---------- message : str - The (main body of the) message to be displayed, which is optionally prefixed by program-name + ': ' + The (main body of the) message to be displayed, which is optionally + prefixed by program-name + ': ' show_program_name : bool, optional - Prevents the default prefixing of the message with program-name + ': ' if `False`. Default is `True` + Prevents the default prefixing of the message with program-name + + ': ' if `False`. Default is `True` """ trailing_prompt = False @@ -227,21 +235,30 @@ def abort( file=None, ): """ - Emits the given message (and optional prefix and suffix) on the contingent report stream, and then terminates the process + Emits the given message (and optional prefix and suffix) on the + contingent report stream, and then terminates the process. Parameters ---------- message : str - The (main body of the) message to be displayed, which is optionally prefixed by program-name + ': ' and optionally suffixed by '; ' + trailing-prompt + The (main body of the) message to be displayed, which is optionally + prefixed by program-name + ': ' and optionally suffixed by '; ' + + trailing-prompt do_exit : bool, optional - Prevents default behaviour of a call to `sys.exit(1)` (after printing the abort message) if `False`. Default is `True` + Prevents default behaviour of a call to `sys.exit(1)` (after + printing the abort message) if `False`. Default is `True` show_program_name : bool, optional - Prevents the default prefixing of the message with program-name + ': ' if `False`. Default is `True` + Prevents the default prefixing of the message with program-name + + ': ' if `False`. Default is `True` trailing_prompt : str, bool, optional - Affects the use of the trailing prompt as follows: if `False`, no trailing prompt is shown; if a (non-empty) string, that is used; if `None`, the default trailing prompt, if any, is used; if `True`, the default trailing prompt is used if specified, otherwise `STOCK_TRAILING_PROMPT` is used + Affects the use of the trailing prompt as follows: if `False`, no + trailing prompt is shown; if a (non-empty) string, that is used; if + `None`, the default trailing prompt, if any, is used; if `True`, the + default trailing prompt is used if specified, otherwise + `STOCK_TRAILING_PROMPT` is used """ file = _get_cr_file_or_default(file) diff --git a/diagnosticism/doomgram.py b/diagnosticism/doomgram.py index fa1a159..fda9574 100644 --- a/diagnosticism/doomgram.py +++ b/diagnosticism/doomgram.py @@ -312,15 +312,25 @@ def to_strip(self, **kwargs): === Signature * *Parameters:* - - +options+ (+Hash+, +Integer+) Combination of flags (with behaviour as described below for the +flags+ option), or an options hash; + - +options+ (+Hash+, +Integer+) Combination of flags (with + behaviour as described below for the +flags+ option), or an + options hash; * *Options:* - - +overflow_character+ (+String+) A string (of length 1) that specifies the symbol for counts outside the available range. Defaults to +'*'+; - - +range+ (+String+) A string whose characters specificy the symbols to use for counts in orders of magnitude. Defaults to +'abcdefghijklmnopqrstuvwxyz'+, which caters to the counts 1-9 => +'a', 10-99 => +'b'+, 100-999 => +'c'+, ... 10^25-(10^26-1) => +'z'+; - - +zero_character+ (+String+) A string (of length 1) that specifies the symbol for a count of 0. Defaults to +' '+; + - +overflow_character+ (+String+) A string (of length 1) that + specifies the symbol for counts outside the available range. + Defaults to +'*'+; + - +range+ (+String+) A string whose characters specificy the + symbols to use for counts in orders of magnitude. Defaults to + +'abcdefghijklmnopqrstuvwxyz'+, which caters to the counts 1-9 + => +'a', 10-99 => +'b'+, 100-999 => +'c'+, ... + 10^25-(10^26-1) => +'z'+; + - +zero_character+ (+String+) A string (of length 1) that + specifies the symbol for a count of 0. Defaults to +' '+; === Return - (+String+) A string (of length 12) containing symbols representing the counts in the ranges 1ns, 10ns, ..., 10s, 100+s. + (+String+) A string (of length 12) containing symbols representing + the counts in the ranges 1ns, 10ns, ..., 10s, 100+s. """ ch_zero = kwargs.get('zero', '_') diff --git a/diagnosticism/logging.py b/diagnosticism/logging.py index df92d95..002a76c 100644 --- a/diagnosticism/logging.py +++ b/diagnosticism/logging.py @@ -18,10 +18,12 @@ _REAL_STDERR = sys.stderr + def _get_stderr_dynamic(): return sys.stderr + def _get_stderr_static(): return _REAL_STDERR @@ -81,7 +83,10 @@ def enable_logging(*args): Parameters ---------- *args - 1 or 2 arguments: if 1, then is a `bool` determining whether should be enabled; if 2, then first is name(s) of environment variable(s) to be parsed and second is a `bool` specifying the default if not found in the environment + 1 or 2 arguments: if 1, then is a `bool` determining whether + enabled; if 2, then first is name(s) of environment variable(s) to + be parsed and second is a `bool` specifying the default if not + found in the environment Returns ------- @@ -91,7 +96,8 @@ def enable_logging(*args): Raises ------ ValueError - If the string form of `v` does not contain a recognisable severity level + If the string form of `v` does not contain a recognisable severity + level """ is_enabled = _bool_from_env(args, 'enable_logging') @@ -129,8 +135,7 @@ def is_severity_logged(severity): if _log_filter: - if isinstance(_log_filter, (dict, )): - + if isinstance(_log_filter, (dict,)): r = _log_filter.get(severity, _others_action) if not r: @@ -150,15 +155,20 @@ def set_log_filter( others_action=None, ): """ - Sets a logging filter, which may either specify a threshold severity or a mapping of levels to actions + Sets a logging filter, which may either specify a threshold severity or + a mapping of levels to actions Parameters ---------- log_filter : int, dict - If a dictionary, it is interpreted as a mapping from severity-level to `True`/`False` that controls each level's output; otherwise, treated as a severity-level threshold (and must be convertible to `int`) + If a dictionary, it is interpreted as a mapping from + severity-level to `True`/`False` that controls each level's output; + otherwise, treated as a severity-level threshold (and must be + convertible to `int`) others_action : obj, optional - An object to be passed to be used in the case that the log_filter is a dictionary and the severity is not recognised + An object to be passed to be used in the case that the log_filter is + a dictionary and the severity is not recognised Returns ------- @@ -205,7 +215,7 @@ def _do_log( message = message() # TODO: perf test this - full = prefix + ': ' + message + full = prefix + ": " + message show_program_name = False trailing_prompt = False @@ -234,7 +244,8 @@ def log( The severity associated with the message message : str, callable - A message string to be emitted, or a callable object that will yield an emittable string when called + A message string to be emitted, or a callable object that will yield + an emittable string when called Returns ------- diff --git a/diagnosticism/severity.py b/diagnosticism/severity.py index 78d4730..5bcf847 100644 --- a/diagnosticism/severity.py +++ b/diagnosticism/severity.py @@ -32,42 +32,60 @@ 'severity_to_string', ] -UNSPECIFIED = 0 -VIOLATION = 1 -"""Severity level suitable for use when logging that a design violation has occurred.""" -ALERT = 2 -"""Severity level suitable for use when logging that a fatal program failure has occurred.""" -CRITICAL = 3 -"""Severity level suitable for use when logging that a critical failure has occurred.""" -FAILURE = 4 -"""Severity level suitable for use when logging that a failure has occurred.""" -WARNING = 5 +UNSPECIFIED = 0 +VIOLATION = 1 +""" +Severity level suitable for use when logging that a design violation has +occurred. +""" +ALERT = 2 +""" +Severity level suitable for use when logging that a fatal program failure +has occurred. +""" +CRITICAL = 3 +""" +Severity level suitable for use when logging that a critical failure has +occurred. +""" +FAILURE = 4 +""" +Severity level suitable for use when logging that a failure has occurred. +""" +WARNING = 5 """Severity level suitable for use when issuing a warning.""" -NOTICE = 6 -"""Severity level suitable for use when logging an important normative condition.""" -INFORMATIONAL = 7 +NOTICE = 6 +""" +Severity level suitable for use when logging an important normative +condition. +""" +INFORMATIONAL = 7 """Severity level suitable for use when logging a normative condition.""" -DEBUG0 = 8 +DEBUG0 = 8 """The highest debug severity level.""" -DEBUG1 = 9 +DEBUG1 = 9 """The second highest debug severity level.""" -DEBUG2 = 10 +DEBUG2 = 10 """The third highest debug severity level.""" -DEBUG3 = 11 +DEBUG3 = 11 """The fourth highest debug severity level.""" -DEBUG4 = 12 +DEBUG4 = 12 """The fifth highest debug severity level.""" -DEBUG5 = 13 +DEBUG5 = 13 """The sixth highest debug severity level.""" -TRACE = 14 -"""Severity level suitable at which trace statements are issued.""" -BENCHMARK = 15 -"""Severity level suitable at which benchmark statements are issued.""" - -DEBUG = DEBUG5 -FAIL = FAILURE -WARN = WARNING -INFO = INFORMATIONAL +TRACE = 14 +""" +Severity level suitable at which trace statements are issued. +""" +BENCHMARK = 15 +""" +Severity level suitable at which benchmark statements are issued. +""" + +DEBUG = DEBUG5 +FAIL = FAILURE +WARN = WARNING +INFO = INFORMATIONAL _STOCK_SEVERITY_LEVELS = { @@ -126,11 +144,9 @@ } if _is_python_3_0_or_later(): - - _INTEGER_TYPES = (int, ) + _INTEGER_TYPES = (int,) else: - - _INTEGER_TYPES = (int, long, ) # noqa: F821 + _INTEGER_TYPES = (int, long,) # noqa: F821 def _parse_verbosity( @@ -143,9 +159,12 @@ def _parse_verbosity( Parameters ---------- v : str | int | * - The variable from which to parse the verbosity. If an integer, is taken as is; if not a string then converted to a string, and in either case is then subject to parsing + The variable from which to parse the verbosity. If an integer, is + taken as is; if not a string then converted to a string, and in + either case is then subject to parsing strict_case_comparison : bool - Specifies whether any string comparison should be strict or permissive + Specifies whether any string comparison should be strict or + permissive Returns ------- @@ -155,7 +174,8 @@ def _parse_verbosity( Raises ------ ValueError - If the string form of `v` does not contain a recognisable severity level + If the string form of `v` does not contain a recognisable severity + level """ if isinstance(v, _INTEGER_TYPES): @@ -200,9 +220,13 @@ def parse_verbosity( Parameters ---------- v : str | int | * - The variable from which to parse the verbosity. If an integer, is taken as is; if not a string is converted to a string, and in either case is then subject to parsing + The variable from which to parse the verbosity. If an integer, + is taken as is; if not a string is converted to a string, and + in either case is then subject to parsing kwargs : dict - Keyword arguments. Currently only `strict_case_comparison` is recognised, which defaults to `False` if not specified by the caller + Keyword arguments. Currently only `strict_case_comparison` is + recognised, which defaults to `False` if not specified by the + caller Returns ------- @@ -212,7 +236,8 @@ def parse_verbosity( Raises ------ ValueError - If the string form of `v` does not contain a recognisable severity level + If the string form of `v` does not contain a recognisable + severity level """ strict_case_comparison = kwargs.get('strict_case_comparison', False) @@ -233,7 +258,9 @@ def parse_verbosity( Parameters ---------- v : str | int | * - The variable from which to parse the verbosity. If an integer, is taken as is; if not a string is converted to a string, and in either case is then subject to parsing + The variable from which to parse the verbosity. If an integer, + is taken as is; if not a string is converted to a string, and + in either case is then subject to parsing Returns ------- @@ -243,7 +270,8 @@ def parse_verbosity( Raises ------ ValueError - If the string form of `v` does not contain a recognisable severity level + If the string form of `v` does not contain a recognisable + severity level """ return _parse_verbosity( diff --git a/diagnosticism/tracing.py b/diagnosticism/tracing.py index 7711fe3..0342ba9 100644 --- a/diagnosticism/tracing.py +++ b/diagnosticism/tracing.py @@ -60,18 +60,14 @@ def _dbg( kwnames = list(kwargs) if 0 != len(args): - - s0 = ', '.join(["(%s)=%s" % (type(arg).__name__, arg) for arg in args]) + s0 = ", ".join(["(%s)=%s" % (type(arg).__name__, arg) for arg in args]) else: - - s0 = '' + s0 = "" if 0 != len(kwargs): - - s1 = ', '.join(["%s(%s)=%s" % (name, type(arg).__name__, arg) for name, arg in kwargs.items()]) + s1 = ", ".join(["%s(%s)=%s" % (name, type(arg).__name__, arg) for name, arg in kwargs.items()]) else: - - s1 = '' + s1 = "" if s0 and s1: @@ -84,7 +80,7 @@ def _dbg( s = s1 else: - s = '' + s = "" if show_fileline: @@ -116,8 +112,7 @@ def _flf( file_name = code.co_filename line_number = fr.f_lineno if '' == code.co_name: - - function_name = "" + function_name = '' else: function_name = code.co_name @@ -126,7 +121,7 @@ def _flf( file_name = _basename(file_name) - return [ file_name, line_number, function_name ] + return [file_name, line_number, function_name] finally: del fr @@ -208,7 +203,10 @@ def enable_tracing(*args): Parameters ---------- *args - 1 or 2 arguments: if 1, then is a `bool` determining whether should be enabled; if 2, then first is name(s) of environment variable(s) to be parsed and second is a `bool` specifying the default if not found in the environment + 1 or 2 arguments: if 1, then is a `bool` determining whether + enabled; if 2, then first is name(s) of environment variable(s) to + be parsed and second is a `bool` specifying the default if not + found in the environment Returns ------- @@ -382,10 +380,12 @@ def tracefunc( file=None, ): """ - Decorator function that equates to the receiver function calling `trace()` + Decorator function that equates to the receiver function calling + `trace()`. Returns ------- + callable A wrapper function that calls the decorated function """ @@ -480,10 +480,12 @@ def asynctracefunc( file=None, ): """ - Async decorator function that equates to the receiver function calling `trace()` + Async decorator function that equates to the receiver function + calling `trace()`. Returns ------- + callable A wrapper function that calls the decorated function """ diff --git a/diagnosticism/warning.py b/diagnosticism/warning.py index 051367b..c8d2160 100644 --- a/diagnosticism/warning.py +++ b/diagnosticism/warning.py @@ -55,16 +55,22 @@ def warn( Parameters ---------- message_lines : list[str] - The message line(s) to be emitted to the standard error stream (along with a new-line sequence). If `None`, nothing is emitted + The message line(s) to be emitted to the standard error stream + (along with a new-line sequence). If `None`, nothing is emitted file : file-object, optional - An object with a `write(str)` method, or `None` (or not present), in which case the default of `sys.stderr` will be used + An object with a `write(str)` method, or `None` (or not present), in + which case the default of `sys.stderr` will be used file_cr : file-object, optional - An object with a `write(str)` method, or `None` (or not present), in which case the default given in `file` will be used. Used for contingent report output + An object with a `write(str)` method, or `None` (or not present), in + which case the default given in `file` will be used. Used for + contingent report output file_dl : file-object, optional - An object with a `write(str)` method, or `None` (or not present), in which case the default given in `file` will be used. Used for diagnostic logging output + An object with a `write(str)` method, or `None` (or not present), in + which case the default given in `file` will be used. Used for + diagnostic logging output Returns ------- diff --git a/setup.py b/setup.py index b3785ab..d4d5879 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ setuptools.setup( name='diagnosticism', - version='0.15.2', + version='0.15.3', author='Matt Wilson', author_email='matthew@synesis.com.au', diff --git a/tests/test_conrep.py b/tests/test_conrep.py index 9c683b2..979ee10 100644 --- a/tests/test_conrep.py +++ b/tests/test_conrep.py @@ -28,7 +28,7 @@ def test_with_program_name(self): set_program_name('myprog1') - conrep('string-1', show_program_name=True) + conrep("string-1", show_program_name=True) self.assertEqual("myprog1: string-1\n", fake_stderr.getvalue()) @@ -36,8 +36,7 @@ def test_with_program_name(self): def test_without_program_name(self): with patch('sys.stderr', new=StringIO()) as fake_stderr: - - conrep('string-1', show_program_name=False) + conrep("string-1", show_program_name=False) self.assertEqual("string-1\n", fake_stderr.getvalue()) @@ -51,7 +50,7 @@ def test_with_program_name(self): set_program_name('myprog1') - report('string-1', show_program_name=True) + report("string-1", show_program_name=True) self.assertEqual("myprog1: string-1\n", fake_stderr.getvalue()) @@ -59,8 +58,7 @@ def test_with_program_name(self): def test_without_program_name(self): with patch('sys.stderr', new=StringIO()) as fake_stderr: - - report('string-1', show_program_name=False) + report("string-1", show_program_name=False) self.assertEqual("string-1\n", fake_stderr.getvalue()) @@ -71,7 +69,7 @@ def test__report__WITH_file_PARAM_AND_show_program_name_False(self): set_program_name('myprog3') - report('string-3', file=file, show_program_name=False) + report("string-3", file=file, show_program_name=False) result = file.getvalue() @@ -84,7 +82,7 @@ def test__report__WITH_file_PARAM_AND_show_program_name_True(self): set_program_name('myprog4') - report('string-4', file=file, show_program_name=True) + report("string-4", file=file, show_program_name=True) result = file.getvalue() @@ -100,7 +98,7 @@ def test_default(self): set_program_name('myprog1') - abort('over and out!', do_exit=False) + abort("over and out!", do_exit=False) self.assertEqual("myprog1: over and out!\n", fake_stderr.getvalue()) @@ -111,7 +109,7 @@ def test_explicit_trailing_prompt(self): set_program_name('myprog1') - abort('over and out!', do_exit=False, trailing_prompt='get over yourself!') + abort("over and out!", do_exit=False, trailing_prompt="get over yourself!") self.assertEqual("myprog1: over and out!; get over yourself!\n", fake_stderr.getvalue()) @@ -122,7 +120,7 @@ def test_stock_trailing_prompt(self): set_program_name('myprog1') - abort('over and out!', do_exit=False, trailing_prompt=True) + abort("over and out!", do_exit=False, trailing_prompt=True) self.assertEqual("myprog1: over and out!; use --help for usage\n", fake_stderr.getvalue()) @@ -133,9 +131,9 @@ def test_set_default_trailing_prompt_1(self): set_program_name('myprog1') - set_default_trailing_prompt('ok, now') + set_default_trailing_prompt("ok, now") - abort('over and out!', do_exit=False, trailing_prompt=True) + abort("over and out!", do_exit=False, trailing_prompt=True) set_default_trailing_prompt(None) @@ -148,9 +146,9 @@ def test_set_default_trailing_prompt_2(self): set_program_name('myprog1') - set_default_trailing_prompt('ok, now') + set_default_trailing_prompt("ok, now") - abort('over and out!', do_exit=False, trailing_prompt=False) + abort("over and out!", do_exit=False, trailing_prompt=False) set_default_trailing_prompt(None) diff --git a/tests/test_log.py b/tests/test_log.py index 8c3f35a..58c2c7f 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -34,9 +34,9 @@ def test_logging_off(self): set_program_name('myprog1') - log(INFORMATIONAL, 'msg-1') + log(INFORMATIONAL, "msg-1") - self.assertEqual('', fake_stderr.getvalue()) + self.assertEqual("", fake_stderr.getvalue()) finally: enable_logging(logging_enabled) @@ -52,7 +52,7 @@ def test_message_as_string(self): set_program_name('myprog1') - log(INFORMATIONAL, 'msg-1') + log(INFORMATIONAL, "msg-1") self.assertRegex(fake_stderr.getvalue(), r'^\[myprog1, \d+, \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}, .*INFORMATIONAL.*\]: msg-1') finally: @@ -70,7 +70,7 @@ def test__log__WITH_str_AND_file_PARAM(self): set_program_name('myprog3') - log(INFORMATIONAL, 'msg-3', file=file) + log(INFORMATIONAL, "msg-3", file=file) finally: enable_logging(logging_enabled) @@ -90,7 +90,7 @@ def test_message_as_lambda(self): set_program_name('myprog1') - log(INFORMATIONAL, lambda: 'msg-1') + log(INFORMATIONAL, lambda: "msg-1") self.assertRegex(fake_stderr.getvalue(), r'^\[myprog1, \d+, \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}, .*INFORMATIONAL.*\]: msg-1') finally: @@ -110,17 +110,17 @@ def test_threshold_filter(self): set_program_name('myprog1') - log(DEBUG0, 'msg-debug0') - log(INFORMATIONAL, 'msg-informational') - log(NOTICE, 'msg-notice') - log(WARNING, 'msg-warning') - log(FAILURE, 'msg-failure') - log(CRITICAL, 'msg-critical') - log(ALERT, 'msg-alert') + log(DEBUG0, "msg-debug0") + log(INFORMATIONAL, "msg-informational") + log(NOTICE, "msg-notice") + log(WARNING, "msg-warning") + log(FAILURE, "msg-failure") + log(CRITICAL, "msg-critical") + log(ALERT, "msg-alert") r = fake_stderr.getvalue().rstrip() - lines = re.split('[\r\n]', r) + lines = re.split("[\r\n]", r) self.assertEqual(4, len(lines)) self.assertRegex(lines[0], r'^\[myprog1, \d+, \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}, .*WARNING.*\]: msg-warning') @@ -153,17 +153,17 @@ def test_dict_filter_others_action_False(self): set_program_name('myprog1') - log(DEBUG0, 'msg-debug0') - log(INFORMATIONAL, 'msg-informational') - log(NOTICE, 'msg-notice') - log(WARNING, 'msg-warning') - log(FAILURE, 'msg-failure') - log(CRITICAL, 'msg-critical') - log(ALERT, 'msg-alert') + log(DEBUG0, "msg-debug0") + log(INFORMATIONAL, "msg-informational") + log(NOTICE, "msg-notice") + log(WARNING, "msg-warning") + log(FAILURE, "msg-failure") + log(CRITICAL, "msg-critical") + log(ALERT, "msg-alert") r = fake_stderr.getvalue().rstrip() - lines = re.split('[\r\n]', r) + lines = re.split("[\r\n]", r) self.assertEqual(3, len(lines)) self.assertRegex(lines[0], r'^\[myprog1, \d+, \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}, .*WARNING.*\]: msg-warning') @@ -195,17 +195,17 @@ def test_dict_filter_others_action_True(self): set_program_name('myprog1') - log(DEBUG0, 'msg-debug0') - log(INFORMATIONAL, 'msg-informational') - log(NOTICE, 'msg-notice') - log(WARNING, 'msg-warning') - log(FAILURE, 'msg-failure') - log(CRITICAL, 'msg-critical') - log(ALERT, 'msg-alert') + log(DEBUG0, "msg-debug0") + log(INFORMATIONAL, "msg-informational") + log(NOTICE, "msg-notice") + log(WARNING, "msg-warning") + log(FAILURE, "msg-failure") + log(CRITICAL, "msg-critical") + log(ALERT, "msg-alert") r = fake_stderr.getvalue().rstrip() - lines = re.split('[\r\n]', r) + lines = re.split("[\r\n]", r) self.assertEqual(6, len(lines)) self.assertRegex(lines[0], r'^\[myprog1, \d+, \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}, .*DEBUG0.*\]: msg-debug0') diff --git a/tests/test_parse_severity.py b/tests/test_parse_severity.py index ce64330..ebd9213 100644 --- a/tests/test_parse_severity.py +++ b/tests/test_parse_severity.py @@ -19,13 +19,12 @@ INTEGER_LEVELS = [ -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, ] NAMED_LEVELS = { - - "Warn" : WARNING, - "Warning" : WARNING, - "WARN" : WARNING, - "WARNING" : WARNING, - "warn" : WARNING, - "warning" : WARNING, + 'Warn' : WARNING, + 'Warning' : WARNING, + 'WARN' : WARNING, + 'WARNING' : WARNING, + 'warn' : WARNING, + 'warning' : WARNING, } diff --git a/tests/test_trace.py b/tests/test_trace.py index 94eb922..9cce851 100755 --- a/tests/test_trace.py +++ b/tests/test_trace.py @@ -63,8 +63,8 @@ def test__trace__WITH_TRACING_DISABLED(self): r = f1() - self.assertEqual('f1-result', r) - self.assertEqual('', file.getvalue()) + self.assertEqual("f1-result", r) + self.assertEqual("", file.getvalue()) finally: file = None diff --git a/tests/test_warn.py b/tests/test_warn.py index e73fd33..01d272f 100755 --- a/tests/test_warn.py +++ b/tests/test_warn.py @@ -48,8 +48,7 @@ def test__warn__WITH_PROGRAM_NAME_AND_LOGGING_DISABLED(self): logging_enabled = True if enable_logging(False) else False try: - - warn('warning-1') + warn("warning-1") finally: enable_logging(logging_enabled) @@ -68,8 +67,7 @@ def test__warn__WITH_PROGRAM_NAME_AND_LOGGING_ENSABLED(self): logging_enabled = True if enable_logging(True) else False try: - - warn('warning-2') + warn("warning-2") finally: enable_logging(logging_enabled) @@ -90,8 +88,7 @@ def test__warn__WITH_file_PARAM_AND_LOGGING_DISABLED(self): logging_enabled = True if enable_logging(False) else False try: - - warn('warning-3', file=file) + warn("warning-3", file=file) finally: enable_logging(logging_enabled) @@ -110,10 +107,8 @@ def test__warn__WITH_file_PARAM_AND_LOGGING_ENABLED(self): logging_enabled = True if enable_logging(True) else False try: - - warn('warning-4', file=file) + warn("warning-4", file=file) finally: - enable_logging(logging_enabled) result = file.getvalue() @@ -133,8 +128,7 @@ def test__warn__WITH_file_cr_PARAM_AND_file_dl_PARAM_AND_LOGGING_ENABLED(self): logging_enabled = True if enable_logging(True) else False try: - - warn('warning-4', file_cr=file_cr, file_dl=file_dl) + warn("warning-4", file_cr=file_cr, file_dl=file_dl) finally: enable_logging(logging_enabled) @@ -159,8 +153,7 @@ def test__warn__WITH_file_PARAM_AND_file_dl_PARAM_AND_LOGGING_ENABLED(self): logging_enabled = True if enable_logging(True) else False try: - - warn('warning-4', file=file, file_dl=file_dl) + warn("warning-4", file=file, file_dl=file_dl) finally: enable_logging(logging_enabled) @@ -185,8 +178,8 @@ def test__warn__WITH_MULTIPLE_LINES_WITH_PROGRAM_NAME_AND_LOGGING_DISABLED(self) try: warn( - 'warning-5a', - 'warning-5b', + "warning-5a", + "warning-5b", ) finally: From 7a904d21a6cf2c6ffd3f5d994ac2bb2bac4d4caf Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Fri, 26 Jun 2026 16:34:40 +1000 Subject: [PATCH 02/18] chore: fixed top-level *-imports --- diagnosticism/__init__.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/diagnosticism/__init__.py b/diagnosticism/__init__.py index 482ab93..955f943 100644 --- a/diagnosticism/__init__.py +++ b/diagnosticism/__init__.py @@ -28,8 +28,33 @@ log, set_log_filter, ) -from .program_name import * -from .severity import * +from .program_name import ( + get_program_name, + set_program_name, +) +from .severity import ( + ALERT, + BENCHMARK, + CRITICAL, + DEBUG0, + DEBUG1, + DEBUG2, + DEBUG3, + DEBUG4, + DEBUG5, + FAIL, + FAILURE, + INFO, + INFORMATIONAL, + NOTICE, + TRACE, + UNSPECIFIED, + VIOLATION, + WARN, + WARNING, + parse_verbosity, + severity_to_string, +) from .tracing import ( dbg, dbgfl, From 36d5eb61d0d99279f88d0a29884b71d6f7308c8f Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Fri, 26 Jun 2026 16:37:24 +1000 Subject: [PATCH 03/18] fix --- diagnosticism/internal/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/diagnosticism/internal/__init__.py b/diagnosticism/internal/__init__.py index bb314d0..b9c6dd1 100644 --- a/diagnosticism/internal/__init__.py +++ b/diagnosticism/internal/__init__.py @@ -166,7 +166,7 @@ def _str2bool(s, default_value=None): return False - s = s.trim().lower() + s = s.strip().lower() if s is not None else None if s in TRUE_STRINGS_lower: From db1b82ed006db7cc00068aed0d622c34ed69d993 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Fri, 26 Jun 2026 16:40:42 +1000 Subject: [PATCH 04/18] doc --- CHANGES.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 526a2ea..4f41478 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,7 +2,9 @@ ## 0.15.3 - 26th June 2026 -* tidying; +* replaced top-level star-imports with explicit imports; +* fixed `_str2bool()` defect; +* general tidying; ## 0.15.2 - 27th August 2025 From 9f7c4866b1f2faf9354d3e24f4bcdf66bbc59de0 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Fri, 26 Jun 2026 16:49:25 +1000 Subject: [PATCH 05/18] chore: added top-level `__all__` documenting the public API --- CHANGES.md | 1 + diagnosticism/__init__.py | 57 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 4f41478..98c321b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,7 @@ ## 0.15.3 - 26th June 2026 * replaced top-level star-imports with explicit imports; +* added top-level `__all__` documenting the public API; * fixed `_str2bool()` defect; * general tidying; diff --git a/diagnosticism/__init__.py b/diagnosticism/__init__.py index 955f943..1755607 100644 --- a/diagnosticism/__init__.py +++ b/diagnosticism/__init__.py @@ -16,6 +16,7 @@ from .contingent_reporting import ( abort, report, + set_default_trailing_prompt, ) from .doomgram import ( DOOMGram, @@ -76,5 +77,61 @@ from .warning import warn +__all__ = [ + '__version__', + + 'ALERT', + 'BENCHMARK', + 'CRITICAL', + 'DEBUG0', + 'DEBUG1', + 'DEBUG2', + 'DEBUG3', + 'DEBUG4', + 'DEBUG5', + 'DOOMGram', + 'DOOMScope', + 'FAIL', + 'FAILURE', + 'INFO', + 'INFORMATIONAL', + 'NOTICE', + 'TRACE', + 'UNSPECIFIED', + 'VIOLATION', + 'WARN', + 'WARNING', + 'abort', + 'dbg', + 'dbgfl', + 'enable_logging', + 'enable_tracing', + 'file', + 'fileline', + 'filelinefunc', + 'func', + 'get_program_name', + 'is_logging_enabled', + 'is_severity_logged', + 'is_tracing_enabled', + 'line', + 'log', + 'parse_verbosity', + 'report', + 'set_default_trailing_prompt', + 'set_log_filter', + 'set_program_name', + 'severity_to_string', + 'trace', + 'warn', +] + +if sys.version_info[:2] >= (3, 9): + __all__.extend([ + 'asynctracefunc', + 'tracefunc', + ]) + + # ############################## end of file ############################# # From 0e4854726b2937de60777a9d5870dabc0069fed8 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Fri, 26 Jun 2026 16:53:25 +1000 Subject: [PATCH 06/18] chore: removed `tests` and `examples` from installable package --- CHANGES.md | 3 ++- MANIFEST.in | 3 +++ __init__.py | 4 +++- setup.py | 6 ++---- 4 files changed, 10 insertions(+), 6 deletions(-) create mode 100644 MANIFEST.in diff --git a/CHANGES.md b/CHANGES.md index 98c321b..293b67b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,8 +2,9 @@ ## 0.15.3 - 26th June 2026 -* replaced top-level star-imports with explicit imports; * added top-level `__all__` documenting the public API; +* replaced top-level star-imports with explicit imports; +* removed `tests` and `examples` from installable package; * fixed `_str2bool()` defect; * general tidying; diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..0dc986e --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include LICENSE README.md CHANGES.md EXAMPLES.md TODO.md +recursive-include examples *.py +recursive-include tests *.py diff --git a/__init__.py b/__init__.py index db720d3..f04f6a1 100644 --- a/__init__.py +++ b/__init__.py @@ -1,2 +1,4 @@ -# This file present to ensure that unittest's discover operates recursively +# This file present to ensure that unittest's discover operates recursively. +# +# It is not exported from the package. diff --git a/setup.py b/setup.py index d4d5879..35290db 100644 --- a/setup.py +++ b/setup.py @@ -29,12 +29,10 @@ license='BSD-3-Clause', long_description=open('README.md').read(), long_description_content_type="text/markdown", - packages=[ - 'diagnosticism', - 'diagnosticism.internal', + packages=setuptools.find_packages(exclude=[ 'examples', 'tests', - ], + ]), url='https://github.com/synesissoftware/diagnosticism.Python', ) From 3659e735d5637694b6dc44b4d14e4dfbbc187145 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Fri, 26 Jun 2026 17:02:42 +1000 Subject: [PATCH 07/18] chore: updated/added build scripts --- build_dist.sh | 10 ++++++++-- build_dist_uv.sh | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100755 build_dist_uv.sh diff --git a/build_dist.sh b/build_dist.sh index 138b9e5..77496c2 100755 --- a/build_dist.sh +++ b/build_dist.sh @@ -1,4 +1,10 @@ -#!/bin/bash +#! /bin/bash -python3 setup.py sdist +set -e +cd "$(dirname "$0")" + +rm -rf build/ dist/ diagnosticism.egg-info/ + +python3 -m build +python3 -m twine check dist/* diff --git a/build_dist_uv.sh b/build_dist_uv.sh new file mode 100755 index 0000000..01b7edd --- /dev/null +++ b/build_dist_uv.sh @@ -0,0 +1,15 @@ +#! /bin/bash + +set -e + +cd "$(dirname "$0")" + +if [ ! -d .venv ]; then + uv venv +fi + +rm -rf build/ dist/ diagnosticism.egg-info/ + +uv pip install build twine +uv run python -m build +uv run twine check dist/* From fe44dd9ad44334500aa699771eb414223f4746d0 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Fri, 26 Jun 2026 17:38:11 +1000 Subject: [PATCH 08/18] chore: restoring broken Python 2.7 compatibility --- .github/workflows/python-package.yml | 24 +++++++++++++++-- CHANGES.md | 1 + README.md | 13 +++++++++ diagnosticism/doomgram.py | 35 ++++++++++++------------ diagnosticism/internal/__init__.py | 20 ++++++++++++++ diagnosticism/tracing.py | 6 ++--- examples/doomgram.py | 3 +++ tests/__init__.py | 19 +++++++++++++ tests/test_doomgram.py | 40 ++++++++++++++-------------- 9 files changed, 119 insertions(+), 42 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 98ab029..0c43e25 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -5,9 +5,9 @@ name: Python package on: push: - branches: [ "master", "dev", "gha" ] + branches: [ "master", "dev", "gha", "tidying" ] pull_request: - branches: [ "master", "dev", "gha" ] + branches: [ "master", "dev", "gha", "tidying" ] jobs: build: @@ -41,3 +41,23 @@ jobs: - name: Test with pytest run: | pytest + + build-py27: + + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v4 + - name: Set up Python 2.7 + uses: actions/setup-python@v5 + with: + python-version: '2.7' + - name: Install dependencies + run: | + python -m pip install --upgrade "pip<21" setuptools mock + pip install -e . + - name: Compile library modules + run: | + python -m compileall diagnosticism + - name: Test with unittest + run: | + python -m unittest discover -s tests diff --git a/CHANGES.md b/CHANGES.md index 293b67b..1d6d0bf 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,6 +4,7 @@ * added top-level `__all__` documenting the public API; * replaced top-level star-imports with explicit imports; +* restoring broken Python 2.7 compatibility; * removed `tests` and `examples` from installable package; * fixed `_str2bool()` defect; * general tidying; diff --git a/README.md b/README.md index 931f34f..b1a4ecf 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Diagnosticism library, for Python - [Introduction](#introduction) - [Installation \& usage](#installation--usage) + - [Python version compatibility](#python-version-compatibility) - [Components](#components) - [Classes](#classes) - [`DOOMGram`](#doomgram) @@ -77,6 +78,18 @@ d.log(sev.INFO, "hello") ``` +### Python version compatibility + +**Diagnosticism.Python** is intended to run on **Python 2.7** and **Python +3.8+**. GitHub Actions currently exercises **Python 3.8–3.13** only. + +| Requirement | Applies to | +| ----------- | ---------- | +| Python **2.7** or **3.8+** | Core contingent reporting, logging, tracing, and severity APIs | +| Python **3.9+** | `@tracefunc` and `@asynctracefunc` decorators | + +The public API surface is listed in `diagnosticism.__all__`. + ## Components diff --git a/diagnosticism/doomgram.py b/diagnosticism/doomgram.py index fda9574..a3987fc 100644 --- a/diagnosticism/doomgram.py +++ b/diagnosticism/doomgram.py @@ -36,8 +36,11 @@ # ######################################################################## # +from .internal import ( + _perf_counter_ns, +) + import math -import time class DOOMGram: @@ -224,33 +227,31 @@ def push_event_time_ns(self, time_in_ns): - if time_in_ns >= 100_000_000: + if time_in_ns >= 100000000: # 100,000,000 - if time_in_ns >= 10_000_000_000: + if time_in_ns >= 10000000000: # 10,000,000,000 - if time_in_ns >= 100_000_000_000: + if time_in_ns >= 100000000000: # 100,000,000,000 self._num_events_ge_100s += 1 else: self._num_events_in_10s += 1 - else: - if time_in_ns >= 1_000_000_000: + if time_in_ns >= 1000000000: # 1,000,000,000 self._num_events_in_1s += 1 else: self._num_events_in_100ms += 1 - else: - if time_in_ns >= 10_000: + if time_in_ns >= 10000: # 10,000 - if time_in_ns >= 1_000_000: + if time_in_ns >= 1000000: # 1,000,000 - if time_in_ns >= 10_000_000: + if time_in_ns >= 10000000: # 10,000,000 self._num_events_in_10ms += 1 else: @@ -259,7 +260,7 @@ def push_event_time_ns(self, time_in_ns): else: - if time_in_ns >= 100_000: + if time_in_ns >= 100000: # 100,000 self._num_events_in_100us += 1 else: @@ -269,7 +270,7 @@ def push_event_time_ns(self, time_in_ns): if time_in_ns >= 100: - if time_in_ns >= 1_000: + if time_in_ns >= 1000: # 1,000 self._num_events_in_1us += 1 else: @@ -289,21 +290,21 @@ def push_event_time_us(self, time_in_us): Pushes an event with the given number of microseconds. """ - self.push_event_time_ns(time_in_us * 1_000) + self.push_event_time_ns(time_in_us * 1000) # 1,000 def push_event_time_ms(self, time_in_ms): """ Pushes an event with the given number of milliseconds. """ - self.push_event_time_ns(time_in_ms * 1_000_000) + self.push_event_time_ns(time_in_ms * 1000000) # 1,000,000 def push_event_time_s(self, time_in_s): """ Pushes an event with the given number of seconds. """ - self.push_event_time_ns(time_in_s * 1_000_000_000) + self.push_event_time_ns(time_in_s * 1000000000) # 1,000,000,000 def to_strip(self, **kwargs): """ @@ -426,13 +427,13 @@ def __init__(self, dg): def __enter__(self): - self._before = time.perf_counter_ns() + self._before = _perf_counter_ns() return self._dg def __exit__(self, x_type, x_val, x_tb): - after = time.perf_counter_ns() + after = _perf_counter_ns() self._dg.push_event_time_ns(after - self._before) diff --git a/diagnosticism/internal/__init__.py b/diagnosticism/internal/__init__.py index b9c6dd1..0e23ad6 100644 --- a/diagnosticism/internal/__init__.py +++ b/diagnosticism/internal/__init__.py @@ -2,6 +2,7 @@ import os import platform import sys +import time TRUE_STRINGS = [ @@ -237,5 +238,24 @@ def _is_python_3_9_or_later(): return sys.version_info[:2] >= (3, 9) +if hasattr(time, 'perf_counter_ns'): + + def _perf_counter_ns(): + """ + Return a performance counter in nanoseconds. + """ + + return time.perf_counter_ns() + +else: + + def _perf_counter_ns(): + """ + Return a performance counter in nanoseconds. + """ + + return int(time.time() * 1000000000) # 1,000,000,000 + + # ############################## end of file ############################# # diff --git a/diagnosticism/tracing.py b/diagnosticism/tracing.py index 0342ba9..0b913a0 100644 --- a/diagnosticism/tracing.py +++ b/diagnosticism/tracing.py @@ -426,19 +426,19 @@ def wrapper(*args, **kwargs): if params_l[-1][1].kind == inspect.Parameter.VAR_KEYWORD: paramK = params_l.pop() - partK = f"{paramK[0]}(dict)={kwargs}" + partK = "%s(dict)=%s" % (paramK[0], kwargs) if len(params_l) != 0: if params_l[-1][1].kind == inspect.Parameter.VAR_POSITIONAL: paramA = params_l.pop() - partA = f"{paramA[0]}(tuple)={args_n[len(params_l):]}" + partA = "%s(tuple)=%s" % (paramA[0], args_n[len(params_l):]) if len(params_l) != 0: namesN = [p[0] for p in params_l] - partN = ', '.join([f"{k}({v.__class__.__name__})={v}" for (k, v) in dict(zip(namesN, args_n)).items()]) + partN = ', '.join(["%s(%s)=%s" % (k, v.__class__.__name__, v) for (k, v) in dict(zip(namesN, args_n)).items()]) fname = func.__name__ diff --git a/examples/doomgram.py b/examples/doomgram.py index af5f771..b0aedee 100755 --- a/examples/doomgram.py +++ b/examples/doomgram.py @@ -1,5 +1,8 @@ #! /usr/bin/env python3 +# NOTE: This example requires Python 3+ (uses DOOMScope / perf_counter_ns and +# PEP 515 numeric literals). + from diagnosticism import ( DOOMGram, DOOMScope, diff --git a/tests/__init__.py b/tests/__init__.py index 2f5dee3..8b38624 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,20 @@ # This file present to deal with warnings from packager + +import sys + +if sys.version_info[0] < 3: + + import unittest + + try: + + import mock + + sys.modules['unittest.mock'] = mock + except ImportError: + + pass + + if not hasattr(unittest.TestCase, 'assertRegex'): + + unittest.TestCase.assertRegex = unittest.TestCase.assertRegexpMatches diff --git a/tests/test_doomgram.py b/tests/test_doomgram.py index 4b464e3..27b585f 100755 --- a/tests/test_doomgram.py +++ b/tests/test_doomgram.py @@ -115,7 +115,7 @@ def test_uniform_spread_timings_1(self): self.assertEqual(789123456789, dg.total_event_time_ns()) self.assertEqual(9, dg.min_event_time_ns()) - self.assertEqual(700_000_000_000, dg.max_event_time_ns()) + self.assertEqual(700000000000, dg.max_event_time_ns()) # 700,000,000,000 self.assertEqual(1, dg.num_events_in_1ns()) self.assertEqual(1, dg.num_events_in_10ns()) @@ -154,7 +154,7 @@ def test_uniform_spread_timings_2(self): self.assertEqual(789123456789, dg.total_event_time_ns()) self.assertEqual(9, dg.min_event_time_ns()) - self.assertEqual(700_000_000_000, dg.max_event_time_ns()) + self.assertEqual(700000000000, dg.max_event_time_ns()) # 700,000,000,000 self.assertEqual(1, dg.num_events_in_1ns()) self.assertEqual(1, dg.num_events_in_10ns()) @@ -193,7 +193,7 @@ def test_uniform_spread_timings_3(self): self.assertEqual(789123456789, dg.total_event_time_ns()) self.assertEqual(9, dg.min_event_time_ns()) - self.assertEqual(700_000_000_000, dg.max_event_time_ns()) + self.assertEqual(700000000000, dg.max_event_time_ns()) # 700,000,000,000 self.assertEqual(1, dg.num_events_in_1ns()) self.assertEqual(1, dg.num_events_in_10ns()) @@ -228,8 +228,8 @@ def test_uniform_spread_timings_4(self): self.assertEqual(9, dg.event_count()) self.assertEqual(789123456000, dg.total_event_time_ns()) - self.assertEqual(6_000, dg.min_event_time_ns()) - self.assertEqual(700_000_000_000, dg.max_event_time_ns()) + self.assertEqual(6000, dg.min_event_time_ns()) # 6,000 + self.assertEqual(700000000000, dg.max_event_time_ns()) # 700,000,000,000 self.assertEqual(0, dg.num_events_in_1ns()) self.assertEqual(0, dg.num_events_in_10ns()) @@ -264,7 +264,7 @@ def test_several_distinct_timings(self): self.assertEqual(314248103033, dg.total_event_time_ns()) self.assertEqual(10, dg.min_event_time_ns()) - self.assertEqual(309_000_000_000, dg.max_event_time_ns()) + self.assertEqual(309000000000, dg.max_event_time_ns()) # 309,000,000,000 self.assertEqual(0, dg.num_events_in_1ns()) self.assertEqual(2, dg.num_events_in_10ns()) @@ -286,22 +286,22 @@ def test_several_intersecting_timings(self): dg = DOOMGram() - dg.push_event_time_ns( 11) - dg.push_event_time_ns( 19) - dg.push_event_time_ns( 19) - dg.push_event_time_us( 7) - dg.push_event_time_us( 7) - dg.push_event_time_us( 89) - dg.push_event_time_ms( 248) - dg.push_event_time_ms(4_321) - dg.push_event_time_s( 5) - dg.push_event_time_s( 309) + dg.push_event_time_ns( 11) + dg.push_event_time_ns( 19) + dg.push_event_time_ns( 19) + dg.push_event_time_us( 7) + dg.push_event_time_us( 7) + dg.push_event_time_us( 89) + dg.push_event_time_ms( 248) + dg.push_event_time_ms(4321) # 4,321 + dg.push_event_time_s( 5) + dg.push_event_time_s( 309) self.assertEqual(10, dg.event_count()) self.assertEqual(318569103049, dg.total_event_time_ns()) self.assertEqual(11, dg.min_event_time_ns()) - self.assertEqual(309_000_000_000, dg.max_event_time_ns()) + self.assertEqual(309000000000, dg.max_event_time_ns()) # 309,000,000,000 self.assertEqual(0, dg.num_events_in_1ns()) self.assertEqual(3, dg.num_events_in_10ns()) @@ -335,12 +335,12 @@ def test_many_cumulative_timings(self): self.assertEqual(10101010000, dg.total_event_time_ns()) self.assertEqual(1, dg.min_event_time_ns()) - self.assertEqual(1_000_000_000, dg.max_event_time_ns()) + self.assertEqual(1000000000, dg.max_event_time_ns()) # 1,000,000,000 - self.assertEqual(10_000, dg.num_events_in_1ns()) + self.assertEqual(10000, dg.num_events_in_1ns()) # 10,000 self.assertEqual(0, dg.num_events_in_10ns()) self.assertEqual(0, dg.num_events_in_100ns()) - self.assertEqual(1_000, dg.num_events_in_1us()) + self.assertEqual(1000, dg.num_events_in_1us()) # 1,000 self.assertEqual(0, dg.num_events_in_10us()) self.assertEqual(0, dg.num_events_in_100us()) self.assertEqual(100, dg.num_events_in_1ms()) From e0afc06137d383b7fed70fd5a73c2fe11903624e Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Fri, 26 Jun 2026 17:47:22 +1000 Subject: [PATCH 09/18] fix --- .github/workflows/python-package.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 0c43e25..27374e8 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -44,13 +44,11 @@ jobs: build-py27: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest + container: + image: python:2.7-slim steps: - uses: actions/checkout@v4 - - name: Set up Python 2.7 - uses: actions/setup-python@v5 - with: - python-version: '2.7' - name: Install dependencies run: | python -m pip install --upgrade "pip<21" setuptools mock From 70c660b60b1ff6bed67a021a1171625f0f33a656 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Fri, 26 Jun 2026 17:55:51 +1000 Subject: [PATCH 10/18] chore: adding missing test file --- tests/test_package.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 tests/test_package.py diff --git a/tests/test_package.py b/tests/test_package.py new file mode 100644 index 0000000..41d0445 --- /dev/null +++ b/tests/test_package.py @@ -0,0 +1,12 @@ +#! /usr/bin/env python3 + +import unittest + +import diagnosticism + + +class Package_tester(unittest.TestCase): + def test_all_names_are_defined(self): + + for name in diagnosticism.__all__: + self.assertTrue(hasattr(diagnosticism, name), name) From 95fd68c7d608d0a76acd4e4db1d55631ae1b5f62 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Fri, 26 Jun 2026 17:57:03 +1000 Subject: [PATCH 11/18] chore: Python 2.x compatibility --- diagnosticism/contingent_reporting.py | 2 +- diagnosticism/severity.py | 2 +- diagnosticism/tracing.py | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/diagnosticism/contingent_reporting.py b/diagnosticism/contingent_reporting.py index b92a59e..373365b 100644 --- a/diagnosticism/contingent_reporting.py +++ b/diagnosticism/contingent_reporting.py @@ -181,7 +181,7 @@ def _do_report( def conrep( message, - **kwargs, + **kwargs ): """ DEPRECATED: use `report()`. diff --git a/diagnosticism/severity.py b/diagnosticism/severity.py index 5bcf847..1563631 100644 --- a/diagnosticism/severity.py +++ b/diagnosticism/severity.py @@ -212,7 +212,7 @@ def _parse_verbosity( def parse_verbosity( v, - **kwargs, + **kwargs ): """ Attempts to parse the verbosity from `v`. diff --git a/diagnosticism/tracing.py b/diagnosticism/tracing.py index 0b913a0..4a045a4 100644 --- a/diagnosticism/tracing.py +++ b/diagnosticism/tracing.py @@ -42,7 +42,7 @@ def _dbg( fr, show_fileline, *args, - **kwargs, + **kwargs ): try: @@ -95,7 +95,7 @@ def _dbg( def _flf( depth=1, - **kwargs, + **kwargs ): assert 1 == depth @@ -144,7 +144,7 @@ def _log_s( def dbgfl( *args, - **kwargs, + **kwargs ): """ Traces arguments and keyword-arguments with file + line. @@ -171,7 +171,7 @@ def dbgfl( def dbg( *args, - **kwargs, + **kwargs ): """ Traces arguments and keyword-arguments. @@ -224,7 +224,7 @@ def enable_tracing(*args): def file( - **kwargs, + **kwargs ): """ Obtains the file of the caller. @@ -238,7 +238,7 @@ def file( def func( - **kwargs, + **kwargs ): """ Obtains the function of the caller. @@ -252,7 +252,7 @@ def func( def line( - **kwargs, + **kwargs ): """ Obtains the line of the caller. @@ -266,7 +266,7 @@ def line( def fileline( - **kwargs, + **kwargs ): """ Obtains a string representing the file and line of the caller. @@ -285,7 +285,7 @@ def fileline( def filelinefunc( - **kwargs, + **kwargs ): """ Obtains a string representing the file, line, and function of the From 65a12fc7aa0bc876281aab7cbee3502147dafc09 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Fri, 26 Jun 2026 17:57:56 +1000 Subject: [PATCH 12/18] chore: Python 2.x compatibility --- diagnosticism/warning.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/diagnosticism/warning.py b/diagnosticism/warning.py index c8d2160..b291ddd 100644 --- a/diagnosticism/warning.py +++ b/diagnosticism/warning.py @@ -45,9 +45,7 @@ def _warn( def warn( *message_lines, - file=None, - file_cr=None, - file_dl=None, + **kwargs ): """ Analogue of Ruby's `Kernel#warn()` @@ -77,6 +75,10 @@ def warn( None """ + file = kwargs.get('file', None) + file_cr = kwargs.get('file_cr', None) + file_dl = kwargs.get('file_dl', None) + if file_cr is None: file_cr = file From 38317441c35566ddfa1edfe32cc89464b716e408 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Fri, 26 Jun 2026 17:58:56 +1000 Subject: [PATCH 13/18] chore: Python 2.x compatibility --- diagnosticism/tracing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/diagnosticism/tracing.py b/diagnosticism/tracing.py index 4a045a4..b85a354 100644 --- a/diagnosticism/tracing.py +++ b/diagnosticism/tracing.py @@ -165,7 +165,7 @@ def dbgfl( fr, True, *args, - **kwargs, + **kwargs ) @@ -192,7 +192,7 @@ def dbg( fr, False, *args, - **kwargs, + **kwargs ) From 6637b48f5c66bde8e2be00dcc26c06b30a6cc2d7 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Fri, 26 Jun 2026 18:04:37 +1000 Subject: [PATCH 14/18] chore: Python 2.x compatibility --- .github/workflows/python-package.yml | 2 +- tests/__init__.py | 4 ++- tests/run_unittest.py | 39 ++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 tests/run_unittest.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 27374e8..4a9550c 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -58,4 +58,4 @@ jobs: python -m compileall diagnosticism - name: Test with unittest run: | - python -m unittest discover -s tests + python tests/run_unittest.py diff --git a/tests/__init__.py b/tests/__init__.py index 8b38624..6c47654 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -13,7 +13,9 @@ sys.modules['unittest.mock'] = mock except ImportError: - pass + raise ImportError( + "the 'mock' package is required to run tests on Python 2.7", + ) if not hasattr(unittest.TestCase, 'assertRegex'): diff --git a/tests/run_unittest.py b/tests/run_unittest.py new file mode 100644 index 0000000..68445fb --- /dev/null +++ b/tests/run_unittest.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python +""" +unittest entry point. + +Imports the `tests` package first so tests/__init__.py can install +Python 2.7 compatibility shims before test modules load. +""" + +import os +import sys +import unittest + + +def main(): + + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + if root not in sys.path: + + sys.path.insert(0, root) + + import tests # noqa: F401 + + start_dir = os.path.join(root, 'tests') + loader = unittest.TestLoader() + suite = loader.discover( + start_dir, + pattern='test_*.py', + top_level_dir=root, + ) + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + sys.exit(0 if result.wasSuccessful() else 1) + + +if __name__ == '__main__': + + main() From 4289734cee74af7a52da631807b4541673bf90a4 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sat, 27 Jun 2026 07:40:21 +1000 Subject: [PATCH 15/18] chore: Python 2.x compatibility --- .github/workflows/python-package.yml | 12 ++++++++++-- CHANGES.md | 3 ++- README.md | 2 +- setup.py | 3 ++- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 4a9550c..737bb88 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -5,9 +5,17 @@ name: Python package on: push: - branches: [ "master", "dev", "gha", "tidying" ] + branches: + - master + - dev + - gha + - tidying pull_request: - branches: [ "master", "dev", "gha", "tidying" ] + branches: + - master + - dev + - gha + - tidying jobs: build: diff --git a/CHANGES.md b/CHANGES.md index 1d6d0bf..5791791 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,12 +1,13 @@ # **Diagnosticism.Python** Changes -## 0.15.3 - 26th June 2026 +## 0.15.3 - 27th June 2026 * added top-level `__all__` documenting the public API; * replaced top-level star-imports with explicit imports; * restoring broken Python 2.7 compatibility; * removed `tests` and `examples` from installable package; * fixed `_str2bool()` defect; +* added `python_requires` for Python 2.7 and Python 3.8+; * general tidying; diff --git a/README.md b/README.md index b1a4ecf..a529ad1 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ d.log(sev.INFO, "hello") ### Python version compatibility **Diagnosticism.Python** is intended to run on **Python 2.7** and **Python -3.8+**. GitHub Actions currently exercises **Python 3.8–3.13** only. +3.8+**. GitHub Actions exercises **Python 2.7** and **Python 3.8–3.13**. | Requirement | Applies to | | ----------- | ---------- | diff --git a/setup.py b/setup.py index 35290db..b10bcbc 100644 --- a/setup.py +++ b/setup.py @@ -5,6 +5,7 @@ name='diagnosticism', version='0.15.3', + python_requires='>=2.7,<3.0 || >=3.8', author='Matt Wilson', author_email='matthew@synesis.com.au', @@ -15,7 +16,7 @@ 'Natural Language :: English', "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", From 62e14c72c5aae33f5a3afa8430e22a07daa09a8a Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sat, 27 Jun 2026 07:42:18 +1000 Subject: [PATCH 16/18] squashed commit --- .github/workflows/python-package.yml | 30 ++++++- CHANGES.md | 11 +++ MANIFEST.in | 3 + README.md | 13 +++ __init__.py | 4 +- build_dist.sh | 10 ++- build_dist_uv.sh | 15 ++++ diagnosticism/__init__.py | 90 ++++++++++++++++++++- diagnosticism/contingent_reporting.py | 41 +++++++--- diagnosticism/doomgram.py | 55 +++++++------ diagnosticism/internal/__init__.py | 22 +++++- diagnosticism/logging.py | 29 ++++--- diagnosticism/severity.py | 110 ++++++++++++++++---------- diagnosticism/tracing.py | 60 +++++++------- diagnosticism/warning.py | 22 ++++-- examples/doomgram.py | 3 + setup.py | 11 ++- tests/__init__.py | 21 +++++ tests/run_unittest.py | 39 +++++++++ tests/test_conrep.py | 28 +++---- tests/test_doomgram.py | 40 +++++----- tests/test_log.py | 58 +++++++------- tests/test_package.py | 12 +++ tests/test_parse_severity.py | 13 ++- tests/test_trace.py | 4 +- tests/test_warn.py | 23 ++---- 26 files changed, 543 insertions(+), 224 deletions(-) create mode 100644 MANIFEST.in create mode 100755 build_dist_uv.sh create mode 100644 tests/run_unittest.py create mode 100644 tests/test_package.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 98ab029..3cd3b3d 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -5,9 +5,17 @@ name: Python package on: push: - branches: [ "master", "dev", "gha" ] + branches: + - master + - dev + - gha + - time_str pull_request: - branches: [ "master", "dev", "gha" ] + branches: + - master + - dev + - gha + - time_str jobs: build: @@ -41,3 +49,21 @@ jobs: - name: Test with pytest run: | pytest + + build-py27: + + runs-on: ubuntu-latest + container: + image: python:2.7-slim + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + run: | + python -m pip install --upgrade "pip<21" setuptools mock + pip install -e . + - name: Compile library modules + run: | + python -m compileall diagnosticism + - name: Test with unittest + run: | + python tests/run_unittest.py diff --git a/CHANGES.md b/CHANGES.md index f7bb7f0..5791791 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,16 @@ # **Diagnosticism.Python** Changes +## 0.15.3 - 27th June 2026 + +* added top-level `__all__` documenting the public API; +* replaced top-level star-imports with explicit imports; +* restoring broken Python 2.7 compatibility; +* removed `tests` and `examples` from installable package; +* fixed `_str2bool()` defect; +* added `python_requires` for Python 2.7 and Python 3.8+; +* general tidying; + + ## 0.15.2 - 27th August 2025 * project boilerplate; diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..0dc986e --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include LICENSE README.md CHANGES.md EXAMPLES.md TODO.md +recursive-include examples *.py +recursive-include tests *.py diff --git a/README.md b/README.md index 931f34f..a529ad1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Diagnosticism library, for Python - [Introduction](#introduction) - [Installation \& usage](#installation--usage) + - [Python version compatibility](#python-version-compatibility) - [Components](#components) - [Classes](#classes) - [`DOOMGram`](#doomgram) @@ -77,6 +78,18 @@ d.log(sev.INFO, "hello") ``` +### Python version compatibility + +**Diagnosticism.Python** is intended to run on **Python 2.7** and **Python +3.8+**. GitHub Actions exercises **Python 2.7** and **Python 3.8–3.13**. + +| Requirement | Applies to | +| ----------- | ---------- | +| Python **2.7** or **3.8+** | Core contingent reporting, logging, tracing, and severity APIs | +| Python **3.9+** | `@tracefunc` and `@asynctracefunc` decorators | + +The public API surface is listed in `diagnosticism.__all__`. + ## Components diff --git a/__init__.py b/__init__.py index db720d3..f04f6a1 100644 --- a/__init__.py +++ b/__init__.py @@ -1,2 +1,4 @@ -# This file present to ensure that unittest's discover operates recursively +# This file present to ensure that unittest's discover operates recursively. +# +# It is not exported from the package. diff --git a/build_dist.sh b/build_dist.sh index 138b9e5..77496c2 100755 --- a/build_dist.sh +++ b/build_dist.sh @@ -1,4 +1,10 @@ -#!/bin/bash +#! /bin/bash -python3 setup.py sdist +set -e +cd "$(dirname "$0")" + +rm -rf build/ dist/ diagnosticism.egg-info/ + +python3 -m build +python3 -m twine check dist/* diff --git a/build_dist_uv.sh b/build_dist_uv.sh new file mode 100755 index 0000000..01b7edd --- /dev/null +++ b/build_dist_uv.sh @@ -0,0 +1,15 @@ +#! /bin/bash + +set -e + +cd "$(dirname "$0")" + +if [ ! -d .venv ]; then + uv venv +fi + +rm -rf build/ dist/ diagnosticism.egg-info/ + +uv pip install build twine +uv run python -m build +uv run twine check dist/* diff --git a/diagnosticism/__init__.py b/diagnosticism/__init__.py index 81dad70..1755607 100644 --- a/diagnosticism/__init__.py +++ b/diagnosticism/__init__.py @@ -9,13 +9,14 @@ __license__ = 'BSD-3-Clause' __maintainer__ = 'Matt Wilson' __status__ = 'Beta' -__version__ = '0.15.2' +__version__ = '0.15.3' import sys from .contingent_reporting import ( abort, report, + set_default_trailing_prompt, ) from .doomgram import ( DOOMGram, @@ -28,8 +29,33 @@ log, set_log_filter, ) -from .program_name import * -from .severity import * +from .program_name import ( + get_program_name, + set_program_name, +) +from .severity import ( + ALERT, + BENCHMARK, + CRITICAL, + DEBUG0, + DEBUG1, + DEBUG2, + DEBUG3, + DEBUG4, + DEBUG5, + FAIL, + FAILURE, + INFO, + INFORMATIONAL, + NOTICE, + TRACE, + UNSPECIFIED, + VIOLATION, + WARN, + WARNING, + parse_verbosity, + severity_to_string, +) from .tracing import ( dbg, dbgfl, @@ -44,12 +70,68 @@ ) if sys.version_info[:2] >= (3, 9): from .tracing import ( - tracefunc, asynctracefunc, + tracefunc, ) from .warning import warn +__all__ = [ + '__version__', + + 'ALERT', + 'BENCHMARK', + 'CRITICAL', + 'DEBUG0', + 'DEBUG1', + 'DEBUG2', + 'DEBUG3', + 'DEBUG4', + 'DEBUG5', + 'DOOMGram', + 'DOOMScope', + 'FAIL', + 'FAILURE', + 'INFO', + 'INFORMATIONAL', + 'NOTICE', + 'TRACE', + 'UNSPECIFIED', + 'VIOLATION', + 'WARN', + 'WARNING', + 'abort', + 'dbg', + 'dbgfl', + 'enable_logging', + 'enable_tracing', + 'file', + 'fileline', + 'filelinefunc', + 'func', + 'get_program_name', + 'is_logging_enabled', + 'is_severity_logged', + 'is_tracing_enabled', + 'line', + 'log', + 'parse_verbosity', + 'report', + 'set_default_trailing_prompt', + 'set_log_filter', + 'set_program_name', + 'severity_to_string', + 'trace', + 'warn', +] + +if sys.version_info[:2] >= (3, 9): + __all__.extend([ + 'asynctracefunc', + 'tracefunc', + ]) + + # ############################## end of file ############################# # diff --git a/diagnosticism/contingent_reporting.py b/diagnosticism/contingent_reporting.py index b3af37e..373365b 100644 --- a/diagnosticism/contingent_reporting.py +++ b/diagnosticism/contingent_reporting.py @@ -25,8 +25,12 @@ def _supports_ansi_sequences(): _OS_IS_POSIX = _supports_ansi_sequences() -STOCK_TRAILING_PROMPT = 'use --help for usage' -"""The trailing prompt used when trailing_prompt=True is passed to abort() (and family) and no default trailing prompt has been set (via set_default_trailing_prompt())""" +STOCK_TRAILING_PROMPT = "use --help for usage" +""" +The trailing prompt used when trailing_prompt=True is passed to abort() (and +family) and no default trailing prompt has been set (via +set_default_trailing_prompt()). +""" _trailing_prompt = None @@ -47,7 +51,8 @@ def set_default_trailing_prompt(prompt): Notes ----- - This method is NOT threadsafe, so in a multithreaded context the set prompt and/or the return value may not be as expected + This method is NOT threadsafe, so in a multithreaded context the set + prompt and/or the return value may not be as expected. """ global _trailing_prompt @@ -176,7 +181,7 @@ def _do_report( def conrep( message, - **kwargs, + **kwargs ): """ DEPRECATED: use `report()`. @@ -196,15 +201,18 @@ def report( file=None, ): """ - Emits the given message (and optional prefix) on the contingent report stream. + Emits the given message (and optional prefix) on the contingent report + stream. Parameters ---------- message : str - The (main body of the) message to be displayed, which is optionally prefixed by program-name + ': ' + The (main body of the) message to be displayed, which is optionally + prefixed by program-name + ': ' show_program_name : bool, optional - Prevents the default prefixing of the message with program-name + ': ' if `False`. Default is `True` + Prevents the default prefixing of the message with program-name + + ': ' if `False`. Default is `True` """ trailing_prompt = False @@ -227,21 +235,30 @@ def abort( file=None, ): """ - Emits the given message (and optional prefix and suffix) on the contingent report stream, and then terminates the process + Emits the given message (and optional prefix and suffix) on the + contingent report stream, and then terminates the process. Parameters ---------- message : str - The (main body of the) message to be displayed, which is optionally prefixed by program-name + ': ' and optionally suffixed by '; ' + trailing-prompt + The (main body of the) message to be displayed, which is optionally + prefixed by program-name + ': ' and optionally suffixed by '; ' + + trailing-prompt do_exit : bool, optional - Prevents default behaviour of a call to `sys.exit(1)` (after printing the abort message) if `False`. Default is `True` + Prevents default behaviour of a call to `sys.exit(1)` (after + printing the abort message) if `False`. Default is `True` show_program_name : bool, optional - Prevents the default prefixing of the message with program-name + ': ' if `False`. Default is `True` + Prevents the default prefixing of the message with program-name + + ': ' if `False`. Default is `True` trailing_prompt : str, bool, optional - Affects the use of the trailing prompt as follows: if `False`, no trailing prompt is shown; if a (non-empty) string, that is used; if `None`, the default trailing prompt, if any, is used; if `True`, the default trailing prompt is used if specified, otherwise `STOCK_TRAILING_PROMPT` is used + Affects the use of the trailing prompt as follows: if `False`, no + trailing prompt is shown; if a (non-empty) string, that is used; if + `None`, the default trailing prompt, if any, is used; if `True`, the + default trailing prompt is used if specified, otherwise + `STOCK_TRAILING_PROMPT` is used """ file = _get_cr_file_or_default(file) diff --git a/diagnosticism/doomgram.py b/diagnosticism/doomgram.py index fa1a159..a3987fc 100644 --- a/diagnosticism/doomgram.py +++ b/diagnosticism/doomgram.py @@ -36,8 +36,11 @@ # ######################################################################## # +from .internal import ( + _perf_counter_ns, +) + import math -import time class DOOMGram: @@ -224,33 +227,31 @@ def push_event_time_ns(self, time_in_ns): - if time_in_ns >= 100_000_000: + if time_in_ns >= 100000000: # 100,000,000 - if time_in_ns >= 10_000_000_000: + if time_in_ns >= 10000000000: # 10,000,000,000 - if time_in_ns >= 100_000_000_000: + if time_in_ns >= 100000000000: # 100,000,000,000 self._num_events_ge_100s += 1 else: self._num_events_in_10s += 1 - else: - if time_in_ns >= 1_000_000_000: + if time_in_ns >= 1000000000: # 1,000,000,000 self._num_events_in_1s += 1 else: self._num_events_in_100ms += 1 - else: - if time_in_ns >= 10_000: + if time_in_ns >= 10000: # 10,000 - if time_in_ns >= 1_000_000: + if time_in_ns >= 1000000: # 1,000,000 - if time_in_ns >= 10_000_000: + if time_in_ns >= 10000000: # 10,000,000 self._num_events_in_10ms += 1 else: @@ -259,7 +260,7 @@ def push_event_time_ns(self, time_in_ns): else: - if time_in_ns >= 100_000: + if time_in_ns >= 100000: # 100,000 self._num_events_in_100us += 1 else: @@ -269,7 +270,7 @@ def push_event_time_ns(self, time_in_ns): if time_in_ns >= 100: - if time_in_ns >= 1_000: + if time_in_ns >= 1000: # 1,000 self._num_events_in_1us += 1 else: @@ -289,21 +290,21 @@ def push_event_time_us(self, time_in_us): Pushes an event with the given number of microseconds. """ - self.push_event_time_ns(time_in_us * 1_000) + self.push_event_time_ns(time_in_us * 1000) # 1,000 def push_event_time_ms(self, time_in_ms): """ Pushes an event with the given number of milliseconds. """ - self.push_event_time_ns(time_in_ms * 1_000_000) + self.push_event_time_ns(time_in_ms * 1000000) # 1,000,000 def push_event_time_s(self, time_in_s): """ Pushes an event with the given number of seconds. """ - self.push_event_time_ns(time_in_s * 1_000_000_000) + self.push_event_time_ns(time_in_s * 1000000000) # 1,000,000,000 def to_strip(self, **kwargs): """ @@ -312,15 +313,25 @@ def to_strip(self, **kwargs): === Signature * *Parameters:* - - +options+ (+Hash+, +Integer+) Combination of flags (with behaviour as described below for the +flags+ option), or an options hash; + - +options+ (+Hash+, +Integer+) Combination of flags (with + behaviour as described below for the +flags+ option), or an + options hash; * *Options:* - - +overflow_character+ (+String+) A string (of length 1) that specifies the symbol for counts outside the available range. Defaults to +'*'+; - - +range+ (+String+) A string whose characters specificy the symbols to use for counts in orders of magnitude. Defaults to +'abcdefghijklmnopqrstuvwxyz'+, which caters to the counts 1-9 => +'a', 10-99 => +'b'+, 100-999 => +'c'+, ... 10^25-(10^26-1) => +'z'+; - - +zero_character+ (+String+) A string (of length 1) that specifies the symbol for a count of 0. Defaults to +' '+; + - +overflow_character+ (+String+) A string (of length 1) that + specifies the symbol for counts outside the available range. + Defaults to +'*'+; + - +range+ (+String+) A string whose characters specificy the + symbols to use for counts in orders of magnitude. Defaults to + +'abcdefghijklmnopqrstuvwxyz'+, which caters to the counts 1-9 + => +'a', 10-99 => +'b'+, 100-999 => +'c'+, ... + 10^25-(10^26-1) => +'z'+; + - +zero_character+ (+String+) A string (of length 1) that + specifies the symbol for a count of 0. Defaults to +' '+; === Return - (+String+) A string (of length 12) containing symbols representing the counts in the ranges 1ns, 10ns, ..., 10s, 100+s. + (+String+) A string (of length 12) containing symbols representing + the counts in the ranges 1ns, 10ns, ..., 10s, 100+s. """ ch_zero = kwargs.get('zero', '_') @@ -416,13 +427,13 @@ def __init__(self, dg): def __enter__(self): - self._before = time.perf_counter_ns() + self._before = _perf_counter_ns() return self._dg def __exit__(self, x_type, x_val, x_tb): - after = time.perf_counter_ns() + after = _perf_counter_ns() self._dg.push_event_time_ns(after - self._before) diff --git a/diagnosticism/internal/__init__.py b/diagnosticism/internal/__init__.py index bb314d0..0e23ad6 100644 --- a/diagnosticism/internal/__init__.py +++ b/diagnosticism/internal/__init__.py @@ -2,6 +2,7 @@ import os import platform import sys +import time TRUE_STRINGS = [ @@ -166,7 +167,7 @@ def _str2bool(s, default_value=None): return False - s = s.trim().lower() + s = s.strip().lower() if s is not None else None if s in TRUE_STRINGS_lower: @@ -237,5 +238,24 @@ def _is_python_3_9_or_later(): return sys.version_info[:2] >= (3, 9) +if hasattr(time, 'perf_counter_ns'): + + def _perf_counter_ns(): + """ + Return a performance counter in nanoseconds. + """ + + return time.perf_counter_ns() + +else: + + def _perf_counter_ns(): + """ + Return a performance counter in nanoseconds. + """ + + return int(time.time() * 1000000000) # 1,000,000,000 + + # ############################## end of file ############################# # diff --git a/diagnosticism/logging.py b/diagnosticism/logging.py index df92d95..002a76c 100644 --- a/diagnosticism/logging.py +++ b/diagnosticism/logging.py @@ -18,10 +18,12 @@ _REAL_STDERR = sys.stderr + def _get_stderr_dynamic(): return sys.stderr + def _get_stderr_static(): return _REAL_STDERR @@ -81,7 +83,10 @@ def enable_logging(*args): Parameters ---------- *args - 1 or 2 arguments: if 1, then is a `bool` determining whether should be enabled; if 2, then first is name(s) of environment variable(s) to be parsed and second is a `bool` specifying the default if not found in the environment + 1 or 2 arguments: if 1, then is a `bool` determining whether + enabled; if 2, then first is name(s) of environment variable(s) to + be parsed and second is a `bool` specifying the default if not + found in the environment Returns ------- @@ -91,7 +96,8 @@ def enable_logging(*args): Raises ------ ValueError - If the string form of `v` does not contain a recognisable severity level + If the string form of `v` does not contain a recognisable severity + level """ is_enabled = _bool_from_env(args, 'enable_logging') @@ -129,8 +135,7 @@ def is_severity_logged(severity): if _log_filter: - if isinstance(_log_filter, (dict, )): - + if isinstance(_log_filter, (dict,)): r = _log_filter.get(severity, _others_action) if not r: @@ -150,15 +155,20 @@ def set_log_filter( others_action=None, ): """ - Sets a logging filter, which may either specify a threshold severity or a mapping of levels to actions + Sets a logging filter, which may either specify a threshold severity or + a mapping of levels to actions Parameters ---------- log_filter : int, dict - If a dictionary, it is interpreted as a mapping from severity-level to `True`/`False` that controls each level's output; otherwise, treated as a severity-level threshold (and must be convertible to `int`) + If a dictionary, it is interpreted as a mapping from + severity-level to `True`/`False` that controls each level's output; + otherwise, treated as a severity-level threshold (and must be + convertible to `int`) others_action : obj, optional - An object to be passed to be used in the case that the log_filter is a dictionary and the severity is not recognised + An object to be passed to be used in the case that the log_filter is + a dictionary and the severity is not recognised Returns ------- @@ -205,7 +215,7 @@ def _do_log( message = message() # TODO: perf test this - full = prefix + ': ' + message + full = prefix + ": " + message show_program_name = False trailing_prompt = False @@ -234,7 +244,8 @@ def log( The severity associated with the message message : str, callable - A message string to be emitted, or a callable object that will yield an emittable string when called + A message string to be emitted, or a callable object that will yield + an emittable string when called Returns ------- diff --git a/diagnosticism/severity.py b/diagnosticism/severity.py index 78d4730..1563631 100644 --- a/diagnosticism/severity.py +++ b/diagnosticism/severity.py @@ -32,42 +32,60 @@ 'severity_to_string', ] -UNSPECIFIED = 0 -VIOLATION = 1 -"""Severity level suitable for use when logging that a design violation has occurred.""" -ALERT = 2 -"""Severity level suitable for use when logging that a fatal program failure has occurred.""" -CRITICAL = 3 -"""Severity level suitable for use when logging that a critical failure has occurred.""" -FAILURE = 4 -"""Severity level suitable for use when logging that a failure has occurred.""" -WARNING = 5 +UNSPECIFIED = 0 +VIOLATION = 1 +""" +Severity level suitable for use when logging that a design violation has +occurred. +""" +ALERT = 2 +""" +Severity level suitable for use when logging that a fatal program failure +has occurred. +""" +CRITICAL = 3 +""" +Severity level suitable for use when logging that a critical failure has +occurred. +""" +FAILURE = 4 +""" +Severity level suitable for use when logging that a failure has occurred. +""" +WARNING = 5 """Severity level suitable for use when issuing a warning.""" -NOTICE = 6 -"""Severity level suitable for use when logging an important normative condition.""" -INFORMATIONAL = 7 +NOTICE = 6 +""" +Severity level suitable for use when logging an important normative +condition. +""" +INFORMATIONAL = 7 """Severity level suitable for use when logging a normative condition.""" -DEBUG0 = 8 +DEBUG0 = 8 """The highest debug severity level.""" -DEBUG1 = 9 +DEBUG1 = 9 """The second highest debug severity level.""" -DEBUG2 = 10 +DEBUG2 = 10 """The third highest debug severity level.""" -DEBUG3 = 11 +DEBUG3 = 11 """The fourth highest debug severity level.""" -DEBUG4 = 12 +DEBUG4 = 12 """The fifth highest debug severity level.""" -DEBUG5 = 13 +DEBUG5 = 13 """The sixth highest debug severity level.""" -TRACE = 14 -"""Severity level suitable at which trace statements are issued.""" -BENCHMARK = 15 -"""Severity level suitable at which benchmark statements are issued.""" - -DEBUG = DEBUG5 -FAIL = FAILURE -WARN = WARNING -INFO = INFORMATIONAL +TRACE = 14 +""" +Severity level suitable at which trace statements are issued. +""" +BENCHMARK = 15 +""" +Severity level suitable at which benchmark statements are issued. +""" + +DEBUG = DEBUG5 +FAIL = FAILURE +WARN = WARNING +INFO = INFORMATIONAL _STOCK_SEVERITY_LEVELS = { @@ -126,11 +144,9 @@ } if _is_python_3_0_or_later(): - - _INTEGER_TYPES = (int, ) + _INTEGER_TYPES = (int,) else: - - _INTEGER_TYPES = (int, long, ) # noqa: F821 + _INTEGER_TYPES = (int, long,) # noqa: F821 def _parse_verbosity( @@ -143,9 +159,12 @@ def _parse_verbosity( Parameters ---------- v : str | int | * - The variable from which to parse the verbosity. If an integer, is taken as is; if not a string then converted to a string, and in either case is then subject to parsing + The variable from which to parse the verbosity. If an integer, is + taken as is; if not a string then converted to a string, and in + either case is then subject to parsing strict_case_comparison : bool - Specifies whether any string comparison should be strict or permissive + Specifies whether any string comparison should be strict or + permissive Returns ------- @@ -155,7 +174,8 @@ def _parse_verbosity( Raises ------ ValueError - If the string form of `v` does not contain a recognisable severity level + If the string form of `v` does not contain a recognisable severity + level """ if isinstance(v, _INTEGER_TYPES): @@ -192,7 +212,7 @@ def _parse_verbosity( def parse_verbosity( v, - **kwargs, + **kwargs ): """ Attempts to parse the verbosity from `v`. @@ -200,9 +220,13 @@ def parse_verbosity( Parameters ---------- v : str | int | * - The variable from which to parse the verbosity. If an integer, is taken as is; if not a string is converted to a string, and in either case is then subject to parsing + The variable from which to parse the verbosity. If an integer, + is taken as is; if not a string is converted to a string, and + in either case is then subject to parsing kwargs : dict - Keyword arguments. Currently only `strict_case_comparison` is recognised, which defaults to `False` if not specified by the caller + Keyword arguments. Currently only `strict_case_comparison` is + recognised, which defaults to `False` if not specified by the + caller Returns ------- @@ -212,7 +236,8 @@ def parse_verbosity( Raises ------ ValueError - If the string form of `v` does not contain a recognisable severity level + If the string form of `v` does not contain a recognisable + severity level """ strict_case_comparison = kwargs.get('strict_case_comparison', False) @@ -233,7 +258,9 @@ def parse_verbosity( Parameters ---------- v : str | int | * - The variable from which to parse the verbosity. If an integer, is taken as is; if not a string is converted to a string, and in either case is then subject to parsing + The variable from which to parse the verbosity. If an integer, + is taken as is; if not a string is converted to a string, and + in either case is then subject to parsing Returns ------- @@ -243,7 +270,8 @@ def parse_verbosity( Raises ------ ValueError - If the string form of `v` does not contain a recognisable severity level + If the string form of `v` does not contain a recognisable + severity level """ return _parse_verbosity( diff --git a/diagnosticism/tracing.py b/diagnosticism/tracing.py index 7711fe3..b85a354 100644 --- a/diagnosticism/tracing.py +++ b/diagnosticism/tracing.py @@ -42,7 +42,7 @@ def _dbg( fr, show_fileline, *args, - **kwargs, + **kwargs ): try: @@ -60,18 +60,14 @@ def _dbg( kwnames = list(kwargs) if 0 != len(args): - - s0 = ', '.join(["(%s)=%s" % (type(arg).__name__, arg) for arg in args]) + s0 = ", ".join(["(%s)=%s" % (type(arg).__name__, arg) for arg in args]) else: - - s0 = '' + s0 = "" if 0 != len(kwargs): - - s1 = ', '.join(["%s(%s)=%s" % (name, type(arg).__name__, arg) for name, arg in kwargs.items()]) + s1 = ", ".join(["%s(%s)=%s" % (name, type(arg).__name__, arg) for name, arg in kwargs.items()]) else: - - s1 = '' + s1 = "" if s0 and s1: @@ -84,7 +80,7 @@ def _dbg( s = s1 else: - s = '' + s = "" if show_fileline: @@ -99,7 +95,7 @@ def _dbg( def _flf( depth=1, - **kwargs, + **kwargs ): assert 1 == depth @@ -116,8 +112,7 @@ def _flf( file_name = code.co_filename line_number = fr.f_lineno if '' == code.co_name: - - function_name = "" + function_name = '' else: function_name = code.co_name @@ -126,7 +121,7 @@ def _flf( file_name = _basename(file_name) - return [ file_name, line_number, function_name ] + return [file_name, line_number, function_name] finally: del fr @@ -149,7 +144,7 @@ def _log_s( def dbgfl( *args, - **kwargs, + **kwargs ): """ Traces arguments and keyword-arguments with file + line. @@ -170,13 +165,13 @@ def dbgfl( fr, True, *args, - **kwargs, + **kwargs ) def dbg( *args, - **kwargs, + **kwargs ): """ Traces arguments and keyword-arguments. @@ -197,7 +192,7 @@ def dbg( fr, False, *args, - **kwargs, + **kwargs ) @@ -208,7 +203,10 @@ def enable_tracing(*args): Parameters ---------- *args - 1 or 2 arguments: if 1, then is a `bool` determining whether should be enabled; if 2, then first is name(s) of environment variable(s) to be parsed and second is a `bool` specifying the default if not found in the environment + 1 or 2 arguments: if 1, then is a `bool` determining whether + enabled; if 2, then first is name(s) of environment variable(s) to + be parsed and second is a `bool` specifying the default if not + found in the environment Returns ------- @@ -226,7 +224,7 @@ def enable_tracing(*args): def file( - **kwargs, + **kwargs ): """ Obtains the file of the caller. @@ -240,7 +238,7 @@ def file( def func( - **kwargs, + **kwargs ): """ Obtains the function of the caller. @@ -254,7 +252,7 @@ def func( def line( - **kwargs, + **kwargs ): """ Obtains the line of the caller. @@ -268,7 +266,7 @@ def line( def fileline( - **kwargs, + **kwargs ): """ Obtains a string representing the file and line of the caller. @@ -287,7 +285,7 @@ def fileline( def filelinefunc( - **kwargs, + **kwargs ): """ Obtains a string representing the file, line, and function of the @@ -382,10 +380,12 @@ def tracefunc( file=None, ): """ - Decorator function that equates to the receiver function calling `trace()` + Decorator function that equates to the receiver function calling + `trace()`. Returns ------- + callable A wrapper function that calls the decorated function """ @@ -426,19 +426,19 @@ def wrapper(*args, **kwargs): if params_l[-1][1].kind == inspect.Parameter.VAR_KEYWORD: paramK = params_l.pop() - partK = f"{paramK[0]}(dict)={kwargs}" + partK = "%s(dict)=%s" % (paramK[0], kwargs) if len(params_l) != 0: if params_l[-1][1].kind == inspect.Parameter.VAR_POSITIONAL: paramA = params_l.pop() - partA = f"{paramA[0]}(tuple)={args_n[len(params_l):]}" + partA = "%s(tuple)=%s" % (paramA[0], args_n[len(params_l):]) if len(params_l) != 0: namesN = [p[0] for p in params_l] - partN = ', '.join([f"{k}({v.__class__.__name__})={v}" for (k, v) in dict(zip(namesN, args_n)).items()]) + partN = ', '.join(["%s(%s)=%s" % (k, v.__class__.__name__, v) for (k, v) in dict(zip(namesN, args_n)).items()]) fname = func.__name__ @@ -480,10 +480,12 @@ def asynctracefunc( file=None, ): """ - Async decorator function that equates to the receiver function calling `trace()` + Async decorator function that equates to the receiver function + calling `trace()`. Returns ------- + callable A wrapper function that calls the decorated function """ diff --git a/diagnosticism/warning.py b/diagnosticism/warning.py index 051367b..b291ddd 100644 --- a/diagnosticism/warning.py +++ b/diagnosticism/warning.py @@ -45,9 +45,7 @@ def _warn( def warn( *message_lines, - file=None, - file_cr=None, - file_dl=None, + **kwargs ): """ Analogue of Ruby's `Kernel#warn()` @@ -55,22 +53,32 @@ def warn( Parameters ---------- message_lines : list[str] - The message line(s) to be emitted to the standard error stream (along with a new-line sequence). If `None`, nothing is emitted + The message line(s) to be emitted to the standard error stream + (along with a new-line sequence). If `None`, nothing is emitted file : file-object, optional - An object with a `write(str)` method, or `None` (or not present), in which case the default of `sys.stderr` will be used + An object with a `write(str)` method, or `None` (or not present), in + which case the default of `sys.stderr` will be used file_cr : file-object, optional - An object with a `write(str)` method, or `None` (or not present), in which case the default given in `file` will be used. Used for contingent report output + An object with a `write(str)` method, or `None` (or not present), in + which case the default given in `file` will be used. Used for + contingent report output file_dl : file-object, optional - An object with a `write(str)` method, or `None` (or not present), in which case the default given in `file` will be used. Used for diagnostic logging output + An object with a `write(str)` method, or `None` (or not present), in + which case the default given in `file` will be used. Used for + diagnostic logging output Returns ------- None """ + file = kwargs.get('file', None) + file_cr = kwargs.get('file_cr', None) + file_dl = kwargs.get('file_dl', None) + if file_cr is None: file_cr = file diff --git a/examples/doomgram.py b/examples/doomgram.py index af5f771..b0aedee 100755 --- a/examples/doomgram.py +++ b/examples/doomgram.py @@ -1,5 +1,8 @@ #! /usr/bin/env python3 +# NOTE: This example requires Python 3+ (uses DOOMScope / perf_counter_ns and +# PEP 515 numeric literals). + from diagnosticism import ( DOOMGram, DOOMScope, diff --git a/setup.py b/setup.py index b3785ab..b10bcbc 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,8 @@ setuptools.setup( name='diagnosticism', - version='0.15.2', + version='0.15.3', + python_requires='>=2.7,<3.0 || >=3.8', author='Matt Wilson', author_email='matthew@synesis.com.au', @@ -15,7 +16,7 @@ 'Natural Language :: English', "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", @@ -29,12 +30,10 @@ license='BSD-3-Clause', long_description=open('README.md').read(), long_description_content_type="text/markdown", - packages=[ - 'diagnosticism', - 'diagnosticism.internal', + packages=setuptools.find_packages(exclude=[ 'examples', 'tests', - ], + ]), url='https://github.com/synesissoftware/diagnosticism.Python', ) diff --git a/tests/__init__.py b/tests/__init__.py index 2f5dee3..6c47654 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,22 @@ # This file present to deal with warnings from packager + +import sys + +if sys.version_info[0] < 3: + + import unittest + + try: + + import mock + + sys.modules['unittest.mock'] = mock + except ImportError: + + raise ImportError( + "the 'mock' package is required to run tests on Python 2.7", + ) + + if not hasattr(unittest.TestCase, 'assertRegex'): + + unittest.TestCase.assertRegex = unittest.TestCase.assertRegexpMatches diff --git a/tests/run_unittest.py b/tests/run_unittest.py new file mode 100644 index 0000000..68445fb --- /dev/null +++ b/tests/run_unittest.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python +""" +unittest entry point. + +Imports the `tests` package first so tests/__init__.py can install +Python 2.7 compatibility shims before test modules load. +""" + +import os +import sys +import unittest + + +def main(): + + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + if root not in sys.path: + + sys.path.insert(0, root) + + import tests # noqa: F401 + + start_dir = os.path.join(root, 'tests') + loader = unittest.TestLoader() + suite = loader.discover( + start_dir, + pattern='test_*.py', + top_level_dir=root, + ) + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + sys.exit(0 if result.wasSuccessful() else 1) + + +if __name__ == '__main__': + + main() diff --git a/tests/test_conrep.py b/tests/test_conrep.py index 9c683b2..979ee10 100644 --- a/tests/test_conrep.py +++ b/tests/test_conrep.py @@ -28,7 +28,7 @@ def test_with_program_name(self): set_program_name('myprog1') - conrep('string-1', show_program_name=True) + conrep("string-1", show_program_name=True) self.assertEqual("myprog1: string-1\n", fake_stderr.getvalue()) @@ -36,8 +36,7 @@ def test_with_program_name(self): def test_without_program_name(self): with patch('sys.stderr', new=StringIO()) as fake_stderr: - - conrep('string-1', show_program_name=False) + conrep("string-1", show_program_name=False) self.assertEqual("string-1\n", fake_stderr.getvalue()) @@ -51,7 +50,7 @@ def test_with_program_name(self): set_program_name('myprog1') - report('string-1', show_program_name=True) + report("string-1", show_program_name=True) self.assertEqual("myprog1: string-1\n", fake_stderr.getvalue()) @@ -59,8 +58,7 @@ def test_with_program_name(self): def test_without_program_name(self): with patch('sys.stderr', new=StringIO()) as fake_stderr: - - report('string-1', show_program_name=False) + report("string-1", show_program_name=False) self.assertEqual("string-1\n", fake_stderr.getvalue()) @@ -71,7 +69,7 @@ def test__report__WITH_file_PARAM_AND_show_program_name_False(self): set_program_name('myprog3') - report('string-3', file=file, show_program_name=False) + report("string-3", file=file, show_program_name=False) result = file.getvalue() @@ -84,7 +82,7 @@ def test__report__WITH_file_PARAM_AND_show_program_name_True(self): set_program_name('myprog4') - report('string-4', file=file, show_program_name=True) + report("string-4", file=file, show_program_name=True) result = file.getvalue() @@ -100,7 +98,7 @@ def test_default(self): set_program_name('myprog1') - abort('over and out!', do_exit=False) + abort("over and out!", do_exit=False) self.assertEqual("myprog1: over and out!\n", fake_stderr.getvalue()) @@ -111,7 +109,7 @@ def test_explicit_trailing_prompt(self): set_program_name('myprog1') - abort('over and out!', do_exit=False, trailing_prompt='get over yourself!') + abort("over and out!", do_exit=False, trailing_prompt="get over yourself!") self.assertEqual("myprog1: over and out!; get over yourself!\n", fake_stderr.getvalue()) @@ -122,7 +120,7 @@ def test_stock_trailing_prompt(self): set_program_name('myprog1') - abort('over and out!', do_exit=False, trailing_prompt=True) + abort("over and out!", do_exit=False, trailing_prompt=True) self.assertEqual("myprog1: over and out!; use --help for usage\n", fake_stderr.getvalue()) @@ -133,9 +131,9 @@ def test_set_default_trailing_prompt_1(self): set_program_name('myprog1') - set_default_trailing_prompt('ok, now') + set_default_trailing_prompt("ok, now") - abort('over and out!', do_exit=False, trailing_prompt=True) + abort("over and out!", do_exit=False, trailing_prompt=True) set_default_trailing_prompt(None) @@ -148,9 +146,9 @@ def test_set_default_trailing_prompt_2(self): set_program_name('myprog1') - set_default_trailing_prompt('ok, now') + set_default_trailing_prompt("ok, now") - abort('over and out!', do_exit=False, trailing_prompt=False) + abort("over and out!", do_exit=False, trailing_prompt=False) set_default_trailing_prompt(None) diff --git a/tests/test_doomgram.py b/tests/test_doomgram.py index 4b464e3..27b585f 100755 --- a/tests/test_doomgram.py +++ b/tests/test_doomgram.py @@ -115,7 +115,7 @@ def test_uniform_spread_timings_1(self): self.assertEqual(789123456789, dg.total_event_time_ns()) self.assertEqual(9, dg.min_event_time_ns()) - self.assertEqual(700_000_000_000, dg.max_event_time_ns()) + self.assertEqual(700000000000, dg.max_event_time_ns()) # 700,000,000,000 self.assertEqual(1, dg.num_events_in_1ns()) self.assertEqual(1, dg.num_events_in_10ns()) @@ -154,7 +154,7 @@ def test_uniform_spread_timings_2(self): self.assertEqual(789123456789, dg.total_event_time_ns()) self.assertEqual(9, dg.min_event_time_ns()) - self.assertEqual(700_000_000_000, dg.max_event_time_ns()) + self.assertEqual(700000000000, dg.max_event_time_ns()) # 700,000,000,000 self.assertEqual(1, dg.num_events_in_1ns()) self.assertEqual(1, dg.num_events_in_10ns()) @@ -193,7 +193,7 @@ def test_uniform_spread_timings_3(self): self.assertEqual(789123456789, dg.total_event_time_ns()) self.assertEqual(9, dg.min_event_time_ns()) - self.assertEqual(700_000_000_000, dg.max_event_time_ns()) + self.assertEqual(700000000000, dg.max_event_time_ns()) # 700,000,000,000 self.assertEqual(1, dg.num_events_in_1ns()) self.assertEqual(1, dg.num_events_in_10ns()) @@ -228,8 +228,8 @@ def test_uniform_spread_timings_4(self): self.assertEqual(9, dg.event_count()) self.assertEqual(789123456000, dg.total_event_time_ns()) - self.assertEqual(6_000, dg.min_event_time_ns()) - self.assertEqual(700_000_000_000, dg.max_event_time_ns()) + self.assertEqual(6000, dg.min_event_time_ns()) # 6,000 + self.assertEqual(700000000000, dg.max_event_time_ns()) # 700,000,000,000 self.assertEqual(0, dg.num_events_in_1ns()) self.assertEqual(0, dg.num_events_in_10ns()) @@ -264,7 +264,7 @@ def test_several_distinct_timings(self): self.assertEqual(314248103033, dg.total_event_time_ns()) self.assertEqual(10, dg.min_event_time_ns()) - self.assertEqual(309_000_000_000, dg.max_event_time_ns()) + self.assertEqual(309000000000, dg.max_event_time_ns()) # 309,000,000,000 self.assertEqual(0, dg.num_events_in_1ns()) self.assertEqual(2, dg.num_events_in_10ns()) @@ -286,22 +286,22 @@ def test_several_intersecting_timings(self): dg = DOOMGram() - dg.push_event_time_ns( 11) - dg.push_event_time_ns( 19) - dg.push_event_time_ns( 19) - dg.push_event_time_us( 7) - dg.push_event_time_us( 7) - dg.push_event_time_us( 89) - dg.push_event_time_ms( 248) - dg.push_event_time_ms(4_321) - dg.push_event_time_s( 5) - dg.push_event_time_s( 309) + dg.push_event_time_ns( 11) + dg.push_event_time_ns( 19) + dg.push_event_time_ns( 19) + dg.push_event_time_us( 7) + dg.push_event_time_us( 7) + dg.push_event_time_us( 89) + dg.push_event_time_ms( 248) + dg.push_event_time_ms(4321) # 4,321 + dg.push_event_time_s( 5) + dg.push_event_time_s( 309) self.assertEqual(10, dg.event_count()) self.assertEqual(318569103049, dg.total_event_time_ns()) self.assertEqual(11, dg.min_event_time_ns()) - self.assertEqual(309_000_000_000, dg.max_event_time_ns()) + self.assertEqual(309000000000, dg.max_event_time_ns()) # 309,000,000,000 self.assertEqual(0, dg.num_events_in_1ns()) self.assertEqual(3, dg.num_events_in_10ns()) @@ -335,12 +335,12 @@ def test_many_cumulative_timings(self): self.assertEqual(10101010000, dg.total_event_time_ns()) self.assertEqual(1, dg.min_event_time_ns()) - self.assertEqual(1_000_000_000, dg.max_event_time_ns()) + self.assertEqual(1000000000, dg.max_event_time_ns()) # 1,000,000,000 - self.assertEqual(10_000, dg.num_events_in_1ns()) + self.assertEqual(10000, dg.num_events_in_1ns()) # 10,000 self.assertEqual(0, dg.num_events_in_10ns()) self.assertEqual(0, dg.num_events_in_100ns()) - self.assertEqual(1_000, dg.num_events_in_1us()) + self.assertEqual(1000, dg.num_events_in_1us()) # 1,000 self.assertEqual(0, dg.num_events_in_10us()) self.assertEqual(0, dg.num_events_in_100us()) self.assertEqual(100, dg.num_events_in_1ms()) diff --git a/tests/test_log.py b/tests/test_log.py index 8c3f35a..58c2c7f 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -34,9 +34,9 @@ def test_logging_off(self): set_program_name('myprog1') - log(INFORMATIONAL, 'msg-1') + log(INFORMATIONAL, "msg-1") - self.assertEqual('', fake_stderr.getvalue()) + self.assertEqual("", fake_stderr.getvalue()) finally: enable_logging(logging_enabled) @@ -52,7 +52,7 @@ def test_message_as_string(self): set_program_name('myprog1') - log(INFORMATIONAL, 'msg-1') + log(INFORMATIONAL, "msg-1") self.assertRegex(fake_stderr.getvalue(), r'^\[myprog1, \d+, \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}, .*INFORMATIONAL.*\]: msg-1') finally: @@ -70,7 +70,7 @@ def test__log__WITH_str_AND_file_PARAM(self): set_program_name('myprog3') - log(INFORMATIONAL, 'msg-3', file=file) + log(INFORMATIONAL, "msg-3", file=file) finally: enable_logging(logging_enabled) @@ -90,7 +90,7 @@ def test_message_as_lambda(self): set_program_name('myprog1') - log(INFORMATIONAL, lambda: 'msg-1') + log(INFORMATIONAL, lambda: "msg-1") self.assertRegex(fake_stderr.getvalue(), r'^\[myprog1, \d+, \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}, .*INFORMATIONAL.*\]: msg-1') finally: @@ -110,17 +110,17 @@ def test_threshold_filter(self): set_program_name('myprog1') - log(DEBUG0, 'msg-debug0') - log(INFORMATIONAL, 'msg-informational') - log(NOTICE, 'msg-notice') - log(WARNING, 'msg-warning') - log(FAILURE, 'msg-failure') - log(CRITICAL, 'msg-critical') - log(ALERT, 'msg-alert') + log(DEBUG0, "msg-debug0") + log(INFORMATIONAL, "msg-informational") + log(NOTICE, "msg-notice") + log(WARNING, "msg-warning") + log(FAILURE, "msg-failure") + log(CRITICAL, "msg-critical") + log(ALERT, "msg-alert") r = fake_stderr.getvalue().rstrip() - lines = re.split('[\r\n]', r) + lines = re.split("[\r\n]", r) self.assertEqual(4, len(lines)) self.assertRegex(lines[0], r'^\[myprog1, \d+, \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}, .*WARNING.*\]: msg-warning') @@ -153,17 +153,17 @@ def test_dict_filter_others_action_False(self): set_program_name('myprog1') - log(DEBUG0, 'msg-debug0') - log(INFORMATIONAL, 'msg-informational') - log(NOTICE, 'msg-notice') - log(WARNING, 'msg-warning') - log(FAILURE, 'msg-failure') - log(CRITICAL, 'msg-critical') - log(ALERT, 'msg-alert') + log(DEBUG0, "msg-debug0") + log(INFORMATIONAL, "msg-informational") + log(NOTICE, "msg-notice") + log(WARNING, "msg-warning") + log(FAILURE, "msg-failure") + log(CRITICAL, "msg-critical") + log(ALERT, "msg-alert") r = fake_stderr.getvalue().rstrip() - lines = re.split('[\r\n]', r) + lines = re.split("[\r\n]", r) self.assertEqual(3, len(lines)) self.assertRegex(lines[0], r'^\[myprog1, \d+, \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}, .*WARNING.*\]: msg-warning') @@ -195,17 +195,17 @@ def test_dict_filter_others_action_True(self): set_program_name('myprog1') - log(DEBUG0, 'msg-debug0') - log(INFORMATIONAL, 'msg-informational') - log(NOTICE, 'msg-notice') - log(WARNING, 'msg-warning') - log(FAILURE, 'msg-failure') - log(CRITICAL, 'msg-critical') - log(ALERT, 'msg-alert') + log(DEBUG0, "msg-debug0") + log(INFORMATIONAL, "msg-informational") + log(NOTICE, "msg-notice") + log(WARNING, "msg-warning") + log(FAILURE, "msg-failure") + log(CRITICAL, "msg-critical") + log(ALERT, "msg-alert") r = fake_stderr.getvalue().rstrip() - lines = re.split('[\r\n]', r) + lines = re.split("[\r\n]", r) self.assertEqual(6, len(lines)) self.assertRegex(lines[0], r'^\[myprog1, \d+, \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}, .*DEBUG0.*\]: msg-debug0') diff --git a/tests/test_package.py b/tests/test_package.py new file mode 100644 index 0000000..41d0445 --- /dev/null +++ b/tests/test_package.py @@ -0,0 +1,12 @@ +#! /usr/bin/env python3 + +import unittest + +import diagnosticism + + +class Package_tester(unittest.TestCase): + def test_all_names_are_defined(self): + + for name in diagnosticism.__all__: + self.assertTrue(hasattr(diagnosticism, name), name) diff --git a/tests/test_parse_severity.py b/tests/test_parse_severity.py index ce64330..ebd9213 100644 --- a/tests/test_parse_severity.py +++ b/tests/test_parse_severity.py @@ -19,13 +19,12 @@ INTEGER_LEVELS = [ -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, ] NAMED_LEVELS = { - - "Warn" : WARNING, - "Warning" : WARNING, - "WARN" : WARNING, - "WARNING" : WARNING, - "warn" : WARNING, - "warning" : WARNING, + 'Warn' : WARNING, + 'Warning' : WARNING, + 'WARN' : WARNING, + 'WARNING' : WARNING, + 'warn' : WARNING, + 'warning' : WARNING, } diff --git a/tests/test_trace.py b/tests/test_trace.py index 94eb922..9cce851 100755 --- a/tests/test_trace.py +++ b/tests/test_trace.py @@ -63,8 +63,8 @@ def test__trace__WITH_TRACING_DISABLED(self): r = f1() - self.assertEqual('f1-result', r) - self.assertEqual('', file.getvalue()) + self.assertEqual("f1-result", r) + self.assertEqual("", file.getvalue()) finally: file = None diff --git a/tests/test_warn.py b/tests/test_warn.py index e73fd33..01d272f 100755 --- a/tests/test_warn.py +++ b/tests/test_warn.py @@ -48,8 +48,7 @@ def test__warn__WITH_PROGRAM_NAME_AND_LOGGING_DISABLED(self): logging_enabled = True if enable_logging(False) else False try: - - warn('warning-1') + warn("warning-1") finally: enable_logging(logging_enabled) @@ -68,8 +67,7 @@ def test__warn__WITH_PROGRAM_NAME_AND_LOGGING_ENSABLED(self): logging_enabled = True if enable_logging(True) else False try: - - warn('warning-2') + warn("warning-2") finally: enable_logging(logging_enabled) @@ -90,8 +88,7 @@ def test__warn__WITH_file_PARAM_AND_LOGGING_DISABLED(self): logging_enabled = True if enable_logging(False) else False try: - - warn('warning-3', file=file) + warn("warning-3", file=file) finally: enable_logging(logging_enabled) @@ -110,10 +107,8 @@ def test__warn__WITH_file_PARAM_AND_LOGGING_ENABLED(self): logging_enabled = True if enable_logging(True) else False try: - - warn('warning-4', file=file) + warn("warning-4", file=file) finally: - enable_logging(logging_enabled) result = file.getvalue() @@ -133,8 +128,7 @@ def test__warn__WITH_file_cr_PARAM_AND_file_dl_PARAM_AND_LOGGING_ENABLED(self): logging_enabled = True if enable_logging(True) else False try: - - warn('warning-4', file_cr=file_cr, file_dl=file_dl) + warn("warning-4", file_cr=file_cr, file_dl=file_dl) finally: enable_logging(logging_enabled) @@ -159,8 +153,7 @@ def test__warn__WITH_file_PARAM_AND_file_dl_PARAM_AND_LOGGING_ENABLED(self): logging_enabled = True if enable_logging(True) else False try: - - warn('warning-4', file=file, file_dl=file_dl) + warn("warning-4", file=file, file_dl=file_dl) finally: enable_logging(logging_enabled) @@ -185,8 +178,8 @@ def test__warn__WITH_MULTIPLE_LINES_WITH_PROGRAM_NAME_AND_LOGGING_DISABLED(self) try: warn( - 'warning-5a', - 'warning-5b', + "warning-5a", + "warning-5b", ) finally: From 5abc47d1a452eb99dbc27430a7fb20c8747cb81e Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sat, 27 Jun 2026 08:51:29 +1000 Subject: [PATCH 17/18] feature: added nanoseconds_to_string() time formatting API (0.16.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduced the time_format package for compact ns/µs/ms/s duration strings, with Python 2.7 and 3.8+ support via version-specific _fmt implementations, unit tests, and README/CI updates for the new API. --- .github/workflows/python-package.yml | 11 +- CHANGES.md | 5 + README.md | 65 ++++++++-- diagnosticism/__init__.py | 6 +- diagnosticism/time_format/__init__.py | 120 ++++++++++++++++++ diagnosticism/time_format/_fmt_py2.py | 42 +++++++ diagnosticism/time_format/_fmt_py3.py | 42 +++++++ diagnosticism/time_format/nanoseconds.py | 93 ++++++++++++++ setup.py | 2 +- tests/test_time_format.py | 150 +++++++++++++++++++++++ 10 files changed, 521 insertions(+), 15 deletions(-) create mode 100644 diagnosticism/time_format/__init__.py create mode 100644 diagnosticism/time_format/_fmt_py2.py create mode 100644 diagnosticism/time_format/_fmt_py3.py create mode 100644 diagnosticism/time_format/nanoseconds.py create mode 100644 tests/test_time_format.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 3cd3b3d..01bd853 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -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 @@ -63,7 +70,7 @@ jobs: pip install -e . - 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 b10bcbc..93b3bb1 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.8', + 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..6da4fae --- /dev/null +++ b/tests/test_time_format.py @@ -0,0 +1,150 @@ +#! /usr/bin/env python3 + +# ######################################################################## # +# 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 ############################# # From ff04322c50ef2c27c41f6734a54c69a467197c6f Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sat, 27 Jun 2026 09:35:57 +1000 Subject: [PATCH 18/18] fix --- tests/test_time_format.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_time_format.py b/tests/test_time_format.py index 6da4fae..94c7495 100644 --- a/tests/test_time_format.py +++ b/tests/test_time_format.py @@ -1,4 +1,5 @@ #! /usr/bin/env python3 +# -*- coding: utf-8 -*- # ######################################################################## # # File: tests/test_time_format.py