diff options
author | Ned Batchelder <ned@nedbatchelder.com> | 2021-10-12 08:46:25 -0400 |
---|---|---|
committer | Ned Batchelder <ned@nedbatchelder.com> | 2021-10-12 08:46:25 -0400 |
commit | 0eaeb99f2de1330a562752d30d02d1898f681cf8 (patch) | |
tree | d51b41a302dddafd3092c0fc367b1676bab56a6d | |
parent | 5b6b6ecb87f4aa1145977b1a4c8359b202da0d7a (diff) | |
download | python-coveragepy-git-0eaeb99f2de1330a562752d30d02d1898f681cf8.tar.gz |
fix: use human sorting on human-readable things
-rw-r--r-- | CHANGES.rst | 4 | ||||
-rw-r--r-- | coverage/cmdline.py | 3 | ||||
-rw-r--r-- | coverage/collector.py | 4 | ||||
-rw-r--r-- | coverage/control.py | 6 | ||||
-rw-r--r-- | coverage/files.py | 4 | ||||
-rw-r--r-- | coverage/html.py | 3 | ||||
-rw-r--r-- | coverage/misc.py | 31 | ||||
-rw-r--r-- | coverage/summary.py | 21 | ||||
-rw-r--r-- | coverage/xmlreport.py | 8 | ||||
-rw-r--r-- | tests/test_misc.py | 21 | ||||
-rw-r--r-- | tests/test_summary.py | 26 |
11 files changed, 100 insertions, 31 deletions
diff --git a/CHANGES.rst b/CHANGES.rst index e45c54ac..f2e2f1bc 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -23,7 +23,9 @@ This list is detailed and covers changes in each pre-release version. Unreleased ---------- -Nothing yet. +- When sorting human-readable names, numeric components are sorted correctly: + file10.py will appear after file9.py. This applies to file names, module + names, environment variables, and test contexts. .. _changes_602: diff --git a/coverage/cmdline.py b/coverage/cmdline.py index 1be155b8..dfdbd1c7 100644 --- a/coverage/cmdline.py +++ b/coverage/cmdline.py @@ -20,6 +20,7 @@ from coverage.data import line_counts from coverage.debug import info_formatter, info_header, short_stack from coverage.exceptions import BaseCoverageException, ExceptionDuringRun, NoSource from coverage.execfile import PyRunner +from coverage.misc import human_sorted from coverage.results import Numbers, should_fail_under @@ -780,7 +781,7 @@ class CoverageScript: if data: print(f"has_arcs: {data.has_arcs()!r}") summary = line_counts(data, fullpath=True) - filenames = sorted(summary.keys()) + filenames = human_sorted(summary.keys()) print(f"\n{len(filenames)} files:") for f in filenames: line = f"{f}: {summary[f]} lines" diff --git a/coverage/collector.py b/coverage/collector.py index 73babf44..733b6f32 100644 --- a/coverage/collector.py +++ b/coverage/collector.py @@ -10,7 +10,7 @@ from coverage import env from coverage.debug import short_stack from coverage.disposition import FileDisposition from coverage.exceptions import CoverageException -from coverage.misc import isolate_module +from coverage.misc import human_sorted, isolate_module from coverage.pytracer import PyTracer os = isolate_module(os) @@ -352,7 +352,7 @@ class Collector: stats = tracer.get_stats() if stats: print("\nCoverage.py tracer stats:") - for k in sorted(stats.keys()): + for k in human_sorted(stats.keys()): print(f"{k:>20}: {stats[k]}") if self.threading: self.threading.settrace(None) diff --git a/coverage/control.py b/coverage/control.py index 8a55a317..defe9209 100644 --- a/coverage/control.py +++ b/coverage/control.py @@ -26,7 +26,7 @@ from coverage.files import PathAliases, abs_file, relative_filename, set_relativ from coverage.html import HtmlReporter from coverage.inorout import InOrOut from coverage.jsonreport import JsonReporter -from coverage.misc import bool_or_none, join_regex +from coverage.misc import bool_or_none, join_regex, human_sorted, human_sorted_items from coverage.misc import DefaultValue, ensure_dir_for_file, isolate_module from coverage.plugin import FileReporter from coverage.plugin_support import Plugins @@ -309,7 +309,7 @@ class Coverage: wrote_any = False with self._debug.without_callers(): if self._debug.should('config'): - config_info = sorted(self.config.__dict__.items()) + config_info = human_sorted_items(self.config.__dict__.items()) config_info = [(k, v) for k, v in config_info if not k.startswith('_')] write_formatted_info(self._debug, "config", config_info) wrote_any = True @@ -1076,7 +1076,7 @@ class Coverage: ('pid', os.getpid()), ('cwd', os.getcwd()), ('path', sys.path), - ('environment', sorted( + ('environment', human_sorted( f"{k} = {v}" for k, v in os.environ.items() if ( diff --git a/coverage/files.py b/coverage/files.py index 68671744..6a4f5906 100644 --- a/coverage/files.py +++ b/coverage/files.py @@ -14,7 +14,7 @@ import sys from coverage import env from coverage.exceptions import CoverageException -from coverage.misc import contract, join_regex, isolate_module +from coverage.misc import contract, human_sorted, isolate_module, join_regex os = isolate_module(os) @@ -199,7 +199,7 @@ class TreeMatcher: """ def __init__(self, paths, name): - self.original_paths = sorted(paths) + self.original_paths = human_sorted(paths) self.paths = list(map(os.path.normcase, paths)) self.name = name diff --git a/coverage/html.py b/coverage/html.py index b095343e..1fbac4b3 100644 --- a/coverage/html.py +++ b/coverage/html.py @@ -15,6 +15,7 @@ from coverage.data import add_data_to_hash from coverage.exceptions import CoverageException from coverage.files import flat_rootname from coverage.misc import ensure_dir, file_be_gone, Hasher, isolate_module, format_local_datetime +from coverage.misc import human_sorted from coverage.report import get_analysis_to_report from coverage.results import Numbers from coverage.templite import Templite @@ -123,7 +124,7 @@ class HtmlDataGeneration: contexts = contexts_label = None context_list = None if category and self.config.show_contexts: - contexts = sorted(c or self.EMPTY for c in contexts_by_lineno.get(lineno, ())) + contexts = human_sorted(c or self.EMPTY for c in contexts_by_lineno.get(lineno, ())) if contexts == [self.EMPTY]: contexts_label = self.EMPTY else: diff --git a/coverage/misc.py b/coverage/misc.py index 29397537..40f00930 100644 --- a/coverage/misc.py +++ b/coverage/misc.py @@ -389,3 +389,34 @@ def import_local_file(modname, modfile=None): spec.loader.exec_module(mod) return mod + + +def human_key(s): + """Turn a string into a list of string and number chunks. + "z23a" -> ["z", 23, "a"] + """ + def tryint(s): + """If `s` is a number, return an int, else `s` unchanged.""" + try: + return int(s) + except ValueError: + return s + + return [tryint(c) for c in re.split(r"(\d+)", s)] + +def human_sorted(strings): + """Sort the given iterable of strings the way that humans expect. + + Numeric components in the strings are sorted as numbers. + + Returns the sorted list. + + """ + return sorted(strings, key=human_key) + +def human_sorted_items(items, reverse=False): + """Sort the (string, value) items the way humans expect. + + Returns the sorted list of items. + """ + return sorted(items, key=lambda pair: (human_key(pair[0]), pair[1]), reverse=reverse) diff --git a/coverage/summary.py b/coverage/summary.py index b7b172f8..0b54a05b 100644 --- a/coverage/summary.py +++ b/coverage/summary.py @@ -6,6 +6,7 @@ import sys from coverage.exceptions import CoverageException +from coverage.misc import human_sorted_items from coverage.report import get_analysis_to_report from coverage.results import Numbers @@ -89,15 +90,17 @@ class SummaryReporter: lines.append((text, args)) # Sort the lines and write them out. - if getattr(self.config, 'sort', None): - sort_option = self.config.sort.lower() - reverse = False - if sort_option[0] == '-': - reverse = True - sort_option = sort_option[1:] - elif sort_option[0] == '+': - sort_option = sort_option[1:] - + sort_option = (self.config.sort or "name").lower() + reverse = False + if sort_option[0] == '-': + reverse = True + sort_option = sort_option[1:] + elif sort_option[0] == '+': + sort_option = sort_option[1:] + + if sort_option == "name": + lines = human_sorted_items(lines, reverse=reverse) + else: position = column_order.get(sort_option) if position is None: raise CoverageException(f"Invalid sorting option: {self.config.sort!r}") diff --git a/coverage/xmlreport.py b/coverage/xmlreport.py index 0538bfd5..6dc330f1 100644 --- a/coverage/xmlreport.py +++ b/coverage/xmlreport.py @@ -10,7 +10,7 @@ import time import xml.dom.minidom from coverage import __url__, __version__, files -from coverage.misc import isolate_module +from coverage.misc import isolate_module, human_sorted, human_sorted_items from coverage.report import get_analysis_to_report os = isolate_module(os) @@ -77,7 +77,7 @@ class XmlReporter: xcoverage.appendChild(xsources) # Populate the XML DOM with the source info. - for path in sorted(self.source_paths): + for path in human_sorted(self.source_paths): xsource = self.xml_out.createElement("source") xsources.appendChild(xsource) txt = self.xml_out.createTextNode(path) @@ -90,13 +90,13 @@ class XmlReporter: xcoverage.appendChild(xpackages) # Populate the XML DOM with the package info. - for pkg_name, pkg_data in sorted(self.packages.items()): + for pkg_name, pkg_data in human_sorted_items(self.packages.items()): class_elts, lhits, lnum, bhits, bnum = pkg_data xpackage = self.xml_out.createElement("package") xpackages.appendChild(xpackage) xclasses = self.xml_out.createElement("classes") xpackage.appendChild(xclasses) - for _, class_elt in sorted(class_elts.items()): + for _, class_elt in human_sorted_items(class_elts.items()): xclasses.appendChild(class_elt) xpackage.setAttribute("name", pkg_name.replace(os.sep, '.')) xpackage.setAttribute("line-rate", rate(lhits, lnum)) diff --git a/tests/test_misc.py b/tests/test_misc.py index 74002232..58dba6a8 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -10,6 +10,7 @@ import pytest from coverage.exceptions import CoverageException from coverage.misc import contract, dummy_decorator_with_args, file_be_gone from coverage.misc import Hasher, one_of, substitute_variables, import_third_party +from coverage.misc import human_sorted, human_sorted_items from coverage.misc import USE_CONTRACTS from tests.coveragetest import CoverageTest @@ -180,3 +181,23 @@ class ImportThirdPartyTest(CoverageTest): mod = import_third_party("xyzzy") assert mod is None assert "xyzzy" not in sys.modules + + +HUMAN_DATA = [ + ("z1 a2z a2a a3 a1", "a1 a2a a2z a3 z1"), + ("a10 a9 a100 a1", "a1 a9 a10 a100"), + ("4.0 3.10-win 3.10-mac 3.9-mac 3.9-win", "3.9-mac 3.9-win 3.10-mac 3.10-win 4.0"), +] + +@pytest.mark.parametrize("words, ordered", HUMAN_DATA) +def test_human_sorted(words, ordered): + assert " ".join(human_sorted(words.split())) == ordered + +@pytest.mark.parametrize("words, ordered", HUMAN_DATA) +def test_human_sorted_items(words, ordered): + keys = words.split() + items = [(k, 1) for k in keys] + [(k, 2) for k in keys] + okeys = ordered.split() + oitems = [(k, v) for k in okeys for v in [1, 2]] + assert human_sorted_items(items) == oitems + assert human_sorted_items(items, reverse=True) == oitems[::-1] diff --git a/tests/test_summary.py b/tests/test_summary.py index b71921c7..4dbd3c08 100644 --- a/tests/test_summary.py +++ b/tests/test_summary.py @@ -847,8 +847,8 @@ class SummaryReporterConfigurationTest(CoverageTest): """ self.make_rigged_file("file1.py", 339, 155) self.make_rigged_file("file2.py", 13, 3) - self.make_rigged_file("file3.py", 234, 228) - self.make_file("doit.py", "import file1, file2, file3") + self.make_rigged_file("file10.py", 234, 228) + self.make_file("doit.py", "import file1, file2, file10") cov = Coverage(source=["."], omit=["doit.py"]) cov.start() @@ -871,7 +871,7 @@ class SummaryReporterConfigurationTest(CoverageTest): # ------------------------------ # file1.py 339 155 54% # file2.py 13 3 77% - # file3.py 234 228 3% + # file10.py 234 228 3% # ------------------------------ # TOTAL 586 386 34% @@ -906,30 +906,40 @@ class SummaryReporterConfigurationTest(CoverageTest): msg = f"The words {words!r} don't appear in order in {text!r}" assert indexes == sorted(indexes), msg + def test_default_sort_report(self): + # Sort the text report by the default (Name) column. + report = self.get_summary_text() + self.assert_ordering(report, "file1.py", "file2.py", "file10.py") + + def test_sort_report_by_name(self): + # Sort the text report explicitly by the Name column. + report = self.get_summary_text(('report:sort', 'Name')) + self.assert_ordering(report, "file1.py", "file2.py", "file10.py") + def test_sort_report_by_stmts(self): # Sort the text report by the Stmts column. report = self.get_summary_text(('report:sort', 'Stmts')) - self.assert_ordering(report, "file2.py", "file3.py", "file1.py") + self.assert_ordering(report, "file2.py", "file10.py", "file1.py") def test_sort_report_by_missing(self): # Sort the text report by the Missing column. report = self.get_summary_text(('report:sort', 'Miss')) - self.assert_ordering(report, "file2.py", "file1.py", "file3.py") + self.assert_ordering(report, "file2.py", "file1.py", "file10.py") def test_sort_report_by_cover(self): # Sort the text report by the Cover column. report = self.get_summary_text(('report:sort', 'Cover')) - self.assert_ordering(report, "file3.py", "file1.py", "file2.py") + self.assert_ordering(report, "file10.py", "file1.py", "file2.py") def test_sort_report_by_cover_plus(self): # Sort the text report by the Cover column, including the explicit + sign. report = self.get_summary_text(('report:sort', '+Cover')) - self.assert_ordering(report, "file3.py", "file1.py", "file2.py") + self.assert_ordering(report, "file10.py", "file1.py", "file2.py") def test_sort_report_by_cover_reversed(self): # Sort the text report by the Cover column reversed. report = self.get_summary_text(('report:sort', '-Cover')) - self.assert_ordering(report, "file2.py", "file1.py", "file3.py") + self.assert_ordering(report, "file2.py", "file1.py", "file10.py") def test_sort_report_by_invalid_option(self): # Sort the text report by a nonsense column. |