Skip to content

Commit efdda88

Browse files
authored
Preserve file order of messages during successive daemon runs (#13780)
This fixes an annoyance where the messages got reshuffled between daemon runs. Also if there are messages from files that didn't generate messages during the previous run, move them towards the end to make them more visible. The implementation is a bit messy since we only have a list of formatted lines where it's most natural to sort the messages, but individual messages can be split across multiple lines. Fix #13141.
1 parent 7819085 commit efdda88

File tree

6 files changed

+192
-13
lines changed

6 files changed

+192
-13
lines changed

mypy/server/update.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@
115115
from __future__ import annotations
116116

117117
import os
118+
import re
118119
import sys
119120
import time
120121
from typing import Callable, NamedTuple, Sequence, Union
@@ -182,7 +183,7 @@ def __init__(self, result: BuildResult) -> None:
182183
# Merge in any root dependencies that may not have been loaded
183184
merge_dependencies(manager.load_fine_grained_deps(FAKE_ROOT_MODULE), self.deps)
184185
self.previous_targets_with_errors = manager.errors.targets()
185-
self.previous_messages = result.errors[:]
186+
self.previous_messages: list[str] = result.errors[:]
186187
# Module, if any, that had blocking errors in the last run as (id, path) tuple.
187188
self.blocking_error: tuple[str, str] | None = None
188189
# Module that we haven't processed yet but that are known to be stale.
@@ -290,6 +291,7 @@ def update(
290291
messages = self.manager.errors.new_messages()
291292
break
292293

294+
messages = sort_messages_preserving_file_order(messages, self.previous_messages)
293295
self.previous_messages = messages[:]
294296
return messages
295297

@@ -1260,3 +1262,61 @@ def refresh_suppressed_submodules(
12601262
state.suppressed.append(submodule)
12611263
state.suppressed_set.add(submodule)
12621264
return messages
1265+
1266+
1267+
def extract_fnam_from_message(message: str) -> str | None:
1268+
m = re.match(r"([^:]+):[0-9]+: (error|note): ", message)
1269+
if m:
1270+
return m.group(1)
1271+
return None
1272+
1273+
1274+
def extract_possible_fnam_from_message(message: str) -> str:
1275+
# This may return non-path things if there is some random colon on the line
1276+
return message.split(":", 1)[0]
1277+
1278+
1279+
def sort_messages_preserving_file_order(
1280+
messages: list[str], prev_messages: list[str]
1281+
) -> list[str]:
1282+
"""Sort messages so that the order of files is preserved.
1283+
1284+
An update generates messages so that the files can be in a fairly
1285+
arbitrary order. Preserve the order of files to avoid messages
1286+
getting reshuffled continuously. If there are messages in
1287+
additional files, sort them towards the end.
1288+
"""
1289+
# Calculate file order from the previous messages
1290+
n = 0
1291+
order = {}
1292+
for msg in prev_messages:
1293+
fnam = extract_fnam_from_message(msg)
1294+
if fnam and fnam not in order:
1295+
order[fnam] = n
1296+
n += 1
1297+
1298+
# Related messages must be sorted as a group of successive lines
1299+
groups = []
1300+
i = 0
1301+
while i < len(messages):
1302+
msg = messages[i]
1303+
maybe_fnam = extract_possible_fnam_from_message(msg)
1304+
group = [msg]
1305+
if maybe_fnam in order:
1306+
# This looks like a file name. Collect all lines related to this message.
1307+
while (
1308+
i + 1 < len(messages)
1309+
and extract_possible_fnam_from_message(messages[i + 1]) not in order
1310+
and extract_fnam_from_message(messages[i + 1]) is None
1311+
and not messages[i + 1].startswith("mypy: ")
1312+
):
1313+
i += 1
1314+
group.append(messages[i])
1315+
groups.append((order.get(maybe_fnam, n), group))
1316+
i += 1
1317+
1318+
groups = sorted(groups, key=lambda g: g[0])
1319+
result = []
1320+
for key, group in groups:
1321+
result.extend(group)
1322+
return result

mypy/test/testfinegrained.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import os
1818
import re
1919
import sys
20+
import unittest
2021
from typing import Any, cast
2122

2223
import pytest
@@ -30,6 +31,7 @@
3031
from mypy.modulefinder import BuildSource
3132
from mypy.options import Options
3233
from mypy.server.mergecheck import check_consistency
34+
from mypy.server.update import sort_messages_preserving_file_order
3335
from mypy.test.config import test_temp_dir
3436
from mypy.test.data import DataDrivenTestCase, DataSuite, DeleteFile, UpdateFile
3537
from mypy.test.helpers import (
@@ -369,3 +371,70 @@ def get_inspect(self, program_text: str, incremental_step: int) -> list[tuple[st
369371

370372
def normalize_messages(messages: list[str]) -> list[str]:
371373
return [re.sub("^tmp" + re.escape(os.sep), "", message) for message in messages]
374+
375+
376+
class TestMessageSorting(unittest.TestCase):
377+
def test_simple_sorting(self) -> None:
378+
msgs = ['x.py:1: error: "int" not callable', 'foo/y.py:123: note: "X" not defined']
379+
old_msgs = ['foo/y.py:12: note: "Y" not defined', 'x.py:8: error: "str" not callable']
380+
assert sort_messages_preserving_file_order(msgs, old_msgs) == list(reversed(msgs))
381+
assert sort_messages_preserving_file_order(list(reversed(msgs)), old_msgs) == list(
382+
reversed(msgs)
383+
)
384+
385+
def test_long_form_sorting(self) -> None:
386+
# Multi-line errors should be sorted together and not split.
387+
msg1 = [
388+
'x.py:1: error: "int" not callable',
389+
"and message continues (x: y)",
390+
" 1()",
391+
" ^~~",
392+
]
393+
msg2 = [
394+
'foo/y.py: In function "f":',
395+
'foo/y.py:123: note: "X" not defined',
396+
"and again message continues",
397+
]
398+
old_msgs = ['foo/y.py:12: note: "Y" not defined', 'x.py:8: error: "str" not callable']
399+
assert sort_messages_preserving_file_order(msg1 + msg2, old_msgs) == msg2 + msg1
400+
assert sort_messages_preserving_file_order(msg2 + msg1, old_msgs) == msg2 + msg1
401+
402+
def test_mypy_error_prefix(self) -> None:
403+
# Some errors don't have a file and start with "mypy: ". These
404+
# shouldn't be sorted together with file-specific errors.
405+
msg1 = 'x.py:1: error: "int" not callable'
406+
msg2 = 'foo/y:123: note: "X" not defined'
407+
msg3 = "mypy: Error not associated with a file"
408+
old_msgs = [
409+
"mypy: Something wrong",
410+
'foo/y:12: note: "Y" not defined',
411+
'x.py:8: error: "str" not callable',
412+
]
413+
assert sort_messages_preserving_file_order([msg1, msg2, msg3], old_msgs) == [
414+
msg2,
415+
msg1,
416+
msg3,
417+
]
418+
assert sort_messages_preserving_file_order([msg3, msg2, msg1], old_msgs) == [
419+
msg2,
420+
msg1,
421+
msg3,
422+
]
423+
424+
def test_new_file_at_the_end(self) -> None:
425+
msg1 = 'x.py:1: error: "int" not callable'
426+
msg2 = 'foo/y.py:123: note: "X" not defined'
427+
new1 = "ab.py:3: error: Problem: error"
428+
new2 = "aaa:3: error: Bad"
429+
old_msgs = ['foo/y.py:12: note: "Y" not defined', 'x.py:8: error: "str" not callable']
430+
assert sort_messages_preserving_file_order([msg1, msg2, new1], old_msgs) == [
431+
msg2,
432+
msg1,
433+
new1,
434+
]
435+
assert sort_messages_preserving_file_order([new1, msg1, msg2, new2], old_msgs) == [
436+
msg2,
437+
msg1,
438+
new1,
439+
new2,
440+
]

test-data/unit/fine-grained-blockers.test

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,8 +317,8 @@ a.py:1: error: invalid syntax
317317
==
318318
a.py:1: error: invalid syntax
319319
==
320-
b.py:3: error: Too many arguments for "f"
321320
a.py:3: error: Too many arguments for "g"
321+
b.py:3: error: Too many arguments for "f"
322322

323323
[case testDeleteFileWithBlockingError-only_when_nocache]
324324
-- Different cache/no-cache tests because:

test-data/unit/fine-grained-follow-imports.test

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -587,8 +587,8 @@ def f() -> None:
587587
main.py:2: error: Cannot find implementation or library stub for module named "p"
588588
main.py:2: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
589589
==
590-
p/m.py:1: error: "str" not callable
591590
p/__init__.py:1: error: "int" not callable
591+
p/m.py:1: error: "str" not callable
592592

593593
[case testFollowImportsNormalPackageInitFileStub]
594594
# flags: --follow-imports=normal
@@ -610,11 +610,11 @@ x x x
610610
main.py:1: error: Cannot find implementation or library stub for module named "p"
611611
main.py:1: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
612612
==
613-
p/m.pyi:1: error: "str" not callable
614613
p/__init__.pyi:1: error: "int" not callable
615-
==
616614
p/m.pyi:1: error: "str" not callable
615+
==
617616
p/__init__.pyi:1: error: "int" not callable
617+
p/m.pyi:1: error: "str" not callable
618618

619619
[case testFollowImportsNormalNamespacePackages]
620620
# flags: --follow-imports=normal --namespace-packages
@@ -638,12 +638,12 @@ main.py:2: error: Cannot find implementation or library stub for module named "p
638638
main.py:2: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
639639
main.py:2: error: Cannot find implementation or library stub for module named "p2"
640640
==
641-
p2/m2.py:1: error: "str" not callable
642641
p1/m1.py:1: error: "int" not callable
642+
p2/m2.py:1: error: "str" not callable
643643
==
644+
p1/m1.py:1: error: "int" not callable
644645
main.py:2: error: Cannot find implementation or library stub for module named "p2.m2"
645646
main.py:2: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
646-
p1/m1.py:1: error: "int" not callable
647647

648648
[case testFollowImportsNormalNewFileOnCommandLine]
649649
# flags: --follow-imports=normal
@@ -659,8 +659,8 @@ p1/m1.py:1: error: "int" not callable
659659
[out]
660660
main.py:1: error: "int" not callable
661661
==
662-
x.py:1: error: "str" not callable
663662
main.py:1: error: "int" not callable
663+
x.py:1: error: "str" not callable
664664

665665
[case testFollowImportsNormalSearchPathUpdate-only_when_nocache]
666666
# flags: --follow-imports=normal
@@ -678,8 +678,8 @@ import bar
678678

679679
[out]
680680
==
681-
src/bar.py:1: error: "int" not callable
682681
src/foo.py:2: error: "str" not callable
682+
src/bar.py:1: error: "int" not callable
683683

684684
[case testFollowImportsNormalSearchPathUpdate2-only_when_cache]
685685
# flags: --follow-imports=normal

test-data/unit/fine-grained-modules.test

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ def f(x: int) -> None: pass
3838
==
3939
a.py:2: error: Incompatible return value type (got "int", expected "str")
4040
==
41-
b.py:2: error: Too many arguments for "f"
4241
a.py:2: error: Incompatible return value type (got "int", expected "str")
42+
b.py:2: error: Too many arguments for "f"
4343
==
4444

4545
[case testAddFileFixesError]

test-data/unit/fine-grained.test

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1814,9 +1814,9 @@ def f() -> Iterator[None]:
18141814
[out]
18151815
main:2: note: Revealed type is "contextlib.GeneratorContextManager[None]"
18161816
==
1817+
main:2: note: Revealed type is "contextlib.GeneratorContextManager[None]"
18171818
a.py:3: error: Cannot find implementation or library stub for module named "b"
18181819
a.py:3: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
1819-
main:2: note: Revealed type is "contextlib.GeneratorContextManager[None]"
18201820
==
18211821
main:2: note: Revealed type is "contextlib.GeneratorContextManager[None]"
18221822

@@ -8689,8 +8689,8 @@ main:2: note: Revealed type is "builtins.int"
86898689
==
86908690
main:2: note: Revealed type is "Literal[1]"
86918691
==
8692-
mod.py:2: error: Incompatible types in assignment (expression has type "Literal[2]", variable has type "Literal[1]")
86938692
main:2: note: Revealed type is "Literal[1]"
8693+
mod.py:2: error: Incompatible types in assignment (expression has type "Literal[2]", variable has type "Literal[1]")
86948694

86958695
[case testLiteralFineGrainedFunctionConversion]
86968696
from mod import foo
@@ -9178,10 +9178,10 @@ a.py:1: error: Type signature has too few arguments
91789178
a.py:5: error: Type signature has too few arguments
91799179
a.py:11: error: Type signature has too few arguments
91809180
==
9181+
c.py:1: error: Type signature has too few arguments
91819182
a.py:1: error: Type signature has too few arguments
91829183
a.py:5: error: Type signature has too few arguments
91839184
a.py:11: error: Type signature has too few arguments
9184-
c.py:1: error: Type signature has too few arguments
91859185

91869186
[case testErrorReportingNewAnalyzer]
91879187
# flags: --disallow-any-generics
@@ -10072,3 +10072,53 @@ class Base(Protocol):
1007210072
[out]
1007310073
==
1007410074
main:6: error: Call to abstract method "meth" of "Base" with trivial body via super() is unsafe
10075+
10076+
[case testPrettyMessageSorting]
10077+
# flags: --pretty
10078+
import a
10079+
10080+
[file a.py]
10081+
1 + ''
10082+
import b
10083+
10084+
[file b.py]
10085+
object + 1
10086+
10087+
[file b.py.2]
10088+
object + 1
10089+
1()
10090+
10091+
[out]
10092+
b.py:1: error: Unsupported left operand type for + ("Type[object]")
10093+
object + 1
10094+
^
10095+
a.py:1: error: Unsupported operand types for + ("int" and "str")
10096+
1 + ''
10097+
^
10098+
==
10099+
b.py:1: error: Unsupported left operand type for + ("Type[object]")
10100+
object + 1
10101+
^
10102+
b.py:2: error: "int" not callable
10103+
1()
10104+
^
10105+
a.py:1: error: Unsupported operand types for + ("int" and "str")
10106+
1 + ''
10107+
^
10108+
[out version>=3.8]
10109+
b.py:1: error: Unsupported left operand type for + ("Type[object]")
10110+
object + 1
10111+
^~~~~~~~~~
10112+
a.py:1: error: Unsupported operand types for + ("int" and "str")
10113+
1 + ''
10114+
^~
10115+
==
10116+
b.py:1: error: Unsupported left operand type for + ("Type[object]")
10117+
object + 1
10118+
^~~~~~~~~~
10119+
b.py:2: error: "int" not callable
10120+
1()
10121+
^~~
10122+
a.py:1: error: Unsupported operand types for + ("int" and "str")
10123+
1 + ''
10124+
^~

0 commit comments

Comments
 (0)