Skip to content

Commit 0eaeb99

Browse files
committed
fix: use human sorting on human-readable things
1 parent 5b6b6ec commit 0eaeb99

11 files changed

+100
-31
lines changed

CHANGES.rst

+3-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ This list is detailed and covers changes in each pre-release version.
2323
Unreleased
2424
----------
2525

26-
Nothing yet.
26+
- When sorting human-readable names, numeric components are sorted correctly:
27+
file10.py will appear after file9.py. This applies to file names, module
28+
names, environment variables, and test contexts.
2729

2830

2931
.. _changes_602:

coverage/cmdline.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from coverage.debug import info_formatter, info_header, short_stack
2121
from coverage.exceptions import BaseCoverageException, ExceptionDuringRun, NoSource
2222
from coverage.execfile import PyRunner
23+
from coverage.misc import human_sorted
2324
from coverage.results import Numbers, should_fail_under
2425

2526

@@ -780,7 +781,7 @@ def do_debug(self, args):
780781
if data:
781782
print(f"has_arcs: {data.has_arcs()!r}")
782783
summary = line_counts(data, fullpath=True)
783-
filenames = sorted(summary.keys())
784+
filenames = human_sorted(summary.keys())
784785
print(f"\n{len(filenames)} files:")
785786
for f in filenames:
786787
line = f"{f}: {summary[f]} lines"

coverage/collector.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from coverage.debug import short_stack
1111
from coverage.disposition import FileDisposition
1212
from coverage.exceptions import CoverageException
13-
from coverage.misc import isolate_module
13+
from coverage.misc import human_sorted, isolate_module
1414
from coverage.pytracer import PyTracer
1515

1616
os = isolate_module(os)
@@ -352,7 +352,7 @@ def pause(self):
352352
stats = tracer.get_stats()
353353
if stats:
354354
print("\nCoverage.py tracer stats:")
355-
for k in sorted(stats.keys()):
355+
for k in human_sorted(stats.keys()):
356356
print(f"{k:>20}: {stats[k]}")
357357
if self.threading:
358358
self.threading.settrace(None)

coverage/control.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from coverage.html import HtmlReporter
2727
from coverage.inorout import InOrOut
2828
from coverage.jsonreport import JsonReporter
29-
from coverage.misc import bool_or_none, join_regex
29+
from coverage.misc import bool_or_none, join_regex, human_sorted, human_sorted_items
3030
from coverage.misc import DefaultValue, ensure_dir_for_file, isolate_module
3131
from coverage.plugin import FileReporter
3232
from coverage.plugin_support import Plugins
@@ -309,7 +309,7 @@ def _write_startup_debug(self):
309309
wrote_any = False
310310
with self._debug.without_callers():
311311
if self._debug.should('config'):
312-
config_info = sorted(self.config.__dict__.items())
312+
config_info = human_sorted_items(self.config.__dict__.items())
313313
config_info = [(k, v) for k, v in config_info if not k.startswith('_')]
314314
write_formatted_info(self._debug, "config", config_info)
315315
wrote_any = True
@@ -1076,7 +1076,7 @@ def plugin_info(plugins):
10761076
('pid', os.getpid()),
10771077
('cwd', os.getcwd()),
10781078
('path', sys.path),
1079-
('environment', sorted(
1079+
('environment', human_sorted(
10801080
f"{k} = {v}"
10811081
for k, v in os.environ.items()
10821082
if (

coverage/files.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from coverage import env
1616
from coverage.exceptions import CoverageException
17-
from coverage.misc import contract, join_regex, isolate_module
17+
from coverage.misc import contract, human_sorted, isolate_module, join_regex
1818

1919

2020
os = isolate_module(os)
@@ -199,7 +199,7 @@ class TreeMatcher:
199199
200200
"""
201201
def __init__(self, paths, name):
202-
self.original_paths = sorted(paths)
202+
self.original_paths = human_sorted(paths)
203203
self.paths = list(map(os.path.normcase, paths))
204204
self.name = name
205205

coverage/html.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from coverage.exceptions import CoverageException
1616
from coverage.files import flat_rootname
1717
from coverage.misc import ensure_dir, file_be_gone, Hasher, isolate_module, format_local_datetime
18+
from coverage.misc import human_sorted
1819
from coverage.report import get_analysis_to_report
1920
from coverage.results import Numbers
2021
from coverage.templite import Templite
@@ -123,7 +124,7 @@ def data_for_file(self, fr, analysis):
123124
contexts = contexts_label = None
124125
context_list = None
125126
if category and self.config.show_contexts:
126-
contexts = sorted(c or self.EMPTY for c in contexts_by_lineno.get(lineno, ()))
127+
contexts = human_sorted(c or self.EMPTY for c in contexts_by_lineno.get(lineno, ()))
127128
if contexts == [self.EMPTY]:
128129
contexts_label = self.EMPTY
129130
else:

coverage/misc.py

+31
Original file line numberDiff line numberDiff line change
@@ -389,3 +389,34 @@ def import_local_file(modname, modfile=None):
389389
spec.loader.exec_module(mod)
390390

391391
return mod
392+
393+
394+
def human_key(s):
395+
"""Turn a string into a list of string and number chunks.
396+
"z23a" -> ["z", 23, "a"]
397+
"""
398+
def tryint(s):
399+
"""If `s` is a number, return an int, else `s` unchanged."""
400+
try:
401+
return int(s)
402+
except ValueError:
403+
return s
404+
405+
return [tryint(c) for c in re.split(r"(\d+)", s)]
406+
407+
def human_sorted(strings):
408+
"""Sort the given iterable of strings the way that humans expect.
409+
410+
Numeric components in the strings are sorted as numbers.
411+
412+
Returns the sorted list.
413+
414+
"""
415+
return sorted(strings, key=human_key)
416+
417+
def human_sorted_items(items, reverse=False):
418+
"""Sort the (string, value) items the way humans expect.
419+
420+
Returns the sorted list of items.
421+
"""
422+
return sorted(items, key=lambda pair: (human_key(pair[0]), pair[1]), reverse=reverse)

coverage/summary.py

+12-9
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import sys
77

88
from coverage.exceptions import CoverageException
9+
from coverage.misc import human_sorted_items
910
from coverage.report import get_analysis_to_report
1011
from coverage.results import Numbers
1112

@@ -89,15 +90,17 @@ def report(self, morfs, outfile=None):
8990
lines.append((text, args))
9091

9192
# Sort the lines and write them out.
92-
if getattr(self.config, 'sort', None):
93-
sort_option = self.config.sort.lower()
94-
reverse = False
95-
if sort_option[0] == '-':
96-
reverse = True
97-
sort_option = sort_option[1:]
98-
elif sort_option[0] == '+':
99-
sort_option = sort_option[1:]
100-
93+
sort_option = (self.config.sort or "name").lower()
94+
reverse = False
95+
if sort_option[0] == '-':
96+
reverse = True
97+
sort_option = sort_option[1:]
98+
elif sort_option[0] == '+':
99+
sort_option = sort_option[1:]
100+
101+
if sort_option == "name":
102+
lines = human_sorted_items(lines, reverse=reverse)
103+
else:
101104
position = column_order.get(sort_option)
102105
if position is None:
103106
raise CoverageException(f"Invalid sorting option: {self.config.sort!r}")

coverage/xmlreport.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import xml.dom.minidom
1111

1212
from coverage import __url__, __version__, files
13-
from coverage.misc import isolate_module
13+
from coverage.misc import isolate_module, human_sorted, human_sorted_items
1414
from coverage.report import get_analysis_to_report
1515

1616
os = isolate_module(os)
@@ -77,7 +77,7 @@ def report(self, morfs, outfile=None):
7777
xcoverage.appendChild(xsources)
7878

7979
# Populate the XML DOM with the source info.
80-
for path in sorted(self.source_paths):
80+
for path in human_sorted(self.source_paths):
8181
xsource = self.xml_out.createElement("source")
8282
xsources.appendChild(xsource)
8383
txt = self.xml_out.createTextNode(path)
@@ -90,13 +90,13 @@ def report(self, morfs, outfile=None):
9090
xcoverage.appendChild(xpackages)
9191

9292
# Populate the XML DOM with the package info.
93-
for pkg_name, pkg_data in sorted(self.packages.items()):
93+
for pkg_name, pkg_data in human_sorted_items(self.packages.items()):
9494
class_elts, lhits, lnum, bhits, bnum = pkg_data
9595
xpackage = self.xml_out.createElement("package")
9696
xpackages.appendChild(xpackage)
9797
xclasses = self.xml_out.createElement("classes")
9898
xpackage.appendChild(xclasses)
99-
for _, class_elt in sorted(class_elts.items()):
99+
for _, class_elt in human_sorted_items(class_elts.items()):
100100
xclasses.appendChild(class_elt)
101101
xpackage.setAttribute("name", pkg_name.replace(os.sep, '.'))
102102
xpackage.setAttribute("line-rate", rate(lhits, lnum))

tests/test_misc.py

+21
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from coverage.exceptions import CoverageException
1111
from coverage.misc import contract, dummy_decorator_with_args, file_be_gone
1212
from coverage.misc import Hasher, one_of, substitute_variables, import_third_party
13+
from coverage.misc import human_sorted, human_sorted_items
1314
from coverage.misc import USE_CONTRACTS
1415

1516
from tests.coveragetest import CoverageTest
@@ -180,3 +181,23 @@ def test_failure(self):
180181
mod = import_third_party("xyzzy")
181182
assert mod is None
182183
assert "xyzzy" not in sys.modules
184+
185+
186+
HUMAN_DATA = [
187+
("z1 a2z a2a a3 a1", "a1 a2a a2z a3 z1"),
188+
("a10 a9 a100 a1", "a1 a9 a10 a100"),
189+
("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"),
190+
]
191+
192+
@pytest.mark.parametrize("words, ordered", HUMAN_DATA)
193+
def test_human_sorted(words, ordered):
194+
assert " ".join(human_sorted(words.split())) == ordered
195+
196+
@pytest.mark.parametrize("words, ordered", HUMAN_DATA)
197+
def test_human_sorted_items(words, ordered):
198+
keys = words.split()
199+
items = [(k, 1) for k in keys] + [(k, 2) for k in keys]
200+
okeys = ordered.split()
201+
oitems = [(k, v) for k in okeys for v in [1, 2]]
202+
assert human_sorted_items(items) == oitems
203+
assert human_sorted_items(items, reverse=True) == oitems[::-1]

tests/test_summary.py

+18-8
Original file line numberDiff line numberDiff line change
@@ -847,8 +847,8 @@ def get_summary_text(self, *options):
847847
"""
848848
self.make_rigged_file("file1.py", 339, 155)
849849
self.make_rigged_file("file2.py", 13, 3)
850-
self.make_rigged_file("file3.py", 234, 228)
851-
self.make_file("doit.py", "import file1, file2, file3")
850+
self.make_rigged_file("file10.py", 234, 228)
851+
self.make_file("doit.py", "import file1, file2, file10")
852852

853853
cov = Coverage(source=["."], omit=["doit.py"])
854854
cov.start()
@@ -871,7 +871,7 @@ def test_test_data(self):
871871
# ------------------------------
872872
# file1.py 339 155 54%
873873
# file2.py 13 3 77%
874-
# file3.py 234 228 3%
874+
# file10.py 234 228 3%
875875
# ------------------------------
876876
# TOTAL 586 386 34%
877877

@@ -906,30 +906,40 @@ def assert_ordering(self, text, *words):
906906
msg = f"The words {words!r} don't appear in order in {text!r}"
907907
assert indexes == sorted(indexes), msg
908908

909+
def test_default_sort_report(self):
910+
# Sort the text report by the default (Name) column.
911+
report = self.get_summary_text()
912+
self.assert_ordering(report, "file1.py", "file2.py", "file10.py")
913+
914+
def test_sort_report_by_name(self):
915+
# Sort the text report explicitly by the Name column.
916+
report = self.get_summary_text(('report:sort', 'Name'))
917+
self.assert_ordering(report, "file1.py", "file2.py", "file10.py")
918+
909919
def test_sort_report_by_stmts(self):
910920
# Sort the text report by the Stmts column.
911921
report = self.get_summary_text(('report:sort', 'Stmts'))
912-
self.assert_ordering(report, "file2.py", "file3.py", "file1.py")
922+
self.assert_ordering(report, "file2.py", "file10.py", "file1.py")
913923

914924
def test_sort_report_by_missing(self):
915925
# Sort the text report by the Missing column.
916926
report = self.get_summary_text(('report:sort', 'Miss'))
917-
self.assert_ordering(report, "file2.py", "file1.py", "file3.py")
927+
self.assert_ordering(report, "file2.py", "file1.py", "file10.py")
918928

919929
def test_sort_report_by_cover(self):
920930
# Sort the text report by the Cover column.
921931
report = self.get_summary_text(('report:sort', 'Cover'))
922-
self.assert_ordering(report, "file3.py", "file1.py", "file2.py")
932+
self.assert_ordering(report, "file10.py", "file1.py", "file2.py")
923933

924934
def test_sort_report_by_cover_plus(self):
925935
# Sort the text report by the Cover column, including the explicit + sign.
926936
report = self.get_summary_text(('report:sort', '+Cover'))
927-
self.assert_ordering(report, "file3.py", "file1.py", "file2.py")
937+
self.assert_ordering(report, "file10.py", "file1.py", "file2.py")
928938

929939
def test_sort_report_by_cover_reversed(self):
930940
# Sort the text report by the Cover column reversed.
931941
report = self.get_summary_text(('report:sort', '-Cover'))
932-
self.assert_ordering(report, "file2.py", "file1.py", "file3.py")
942+
self.assert_ordering(report, "file2.py", "file1.py", "file10.py")
933943

934944
def test_sort_report_by_invalid_option(self):
935945
# Sort the text report by a nonsense column.

0 commit comments

Comments
 (0)