8000 Flush error messages incrementally after processing a file by msullivan · Pull Request #4396 · python/mypy · GitHub
[go: up one dir, main page]

Skip to content

Flush error messages incrementally after processing a file #4396

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Jan 9, 2018
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Add more tests and stream blocking errors as well
  • Loading branch information
msullivan committed Jan 4, 2018
commit 76f945fc47193da18240172e41cd3d9fa883a600
30 changes: 25 additions & 5 deletions mypy/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@
import time
from os.path import dirname, basename
import errno
from functools import wraps
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need this any more.


from typing import (AbstractSet, Any, cast, Dict, Iterable, Iterator, List,
Mapping, NamedTuple, Optional, Set, Tuple, Union, Callable)
Mapping, NamedTuple, Optional, Set, Tuple, TypeVar, Union, Callable)
# Can't use TYPE_CHECKING because it's not in the Python 3.5.1 stdlib
MYPY = False
if MYPY:
Expand Down Expand Up @@ -127,13 +128,32 @@ def is_source(self, file: MypyFile) -> bool:
# be updated in place with newly computed cache data. See dmypy.py.
SavedCache = Dict[str, Tuple['CacheMeta', MypyFile, Dict[Expression, Type]]]

F = TypeVar('F', bound=Callable[..., Any])


def flush_compile_errors(f: F) -> F:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest not to make this a decorator at all, but a wrapper function. You could rename build -> _build and then create a new build function with the same signature that calls _build and catches CompileError etc. You wouldn't need a type variable and the code smell would be less -- no higher-order function, no functools, no TypeVar, no cast, no kwargs.get(). (The price would be more repetition in the argument lists but that's all KISS. :-)

"""Catch and flush out any messages from a CompileError thrown in build."""
@wraps(f)
def func(*args, **kwargs):
# type: (*Any, **Any) -> Any
try:
return f(*args, **kwargs)
except CompileError as e:
serious = not e.use_stdout
error_flush = kwargs.get('flush_errors', None)
if error_flush:
error_flush(e.messages[e.num_already_seen:], serious)
raise
return cast(F, func)


@flush_compile_errors
def build(sources: List[BuildSource],
options: Options,
alt_lib_path: Optional[str] = None,
bin_dir: Optional[str] = None,
saved_cache: Optional[SavedCache] = None,
flush_errors: Optional[Callable[[List[str]], None]] = None,
flush_errors: Optional[Callable[[List[str], bool], None]] = None,
plugin: Optional[Plugin] = None,
) -> BuildResult:
"""Analyze a program.
Expand Down Expand Up @@ -539,7 +559,7 @@ def __init__(self, data_dir: str,
version_id: str,
plugin: Plugin,
errors: Errors,
flush_errors: Optional[Callable[[List[str]], None]] = None,
flush_errors: Optional[Callable[[List[str], bool], None]] = None,
saved_cache: Optional[SavedCache] = None,
) -> None:
self.start_time = time.time()
Expand Down Expand Up @@ -712,9 +732,9 @@ def add_stats(self, **kwds: Any) -> None:
def stats_summary(self) -> Mapping[str, object]:
return self.stats

def error_flush(self, msgs: List[str]) -> None:
def error_flush(self, msgs: List[str], serious: bool=False) -> None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider getting rid of this and just inlining the only call site to add serious=False?

if self.flush_errors:
self.flush_errors(msgs)
self.flush_errors(msgs, serious)


def remove_cwd_prefix_from_path(p: str) -> str:
Expand Down
2 changes: 1 addition & 1 deletion mypy/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ def messages(self) -> List[str]:

Use a form suitable for displaying to the user.
"""
self.new_messages()
self.new_messages() # Updates formatted_messages as a side effect
return self.formatted_messages

def targets(self) -> Set[str]:
Expand Down
13 changes: 4 additions & 9 deletions mypy/main.py
9E88
Original file line number Diff line number Diff line change
Expand Up @@ -61,28 +61,23 @@ def main(script_path: Optional[str], args: Optional[List[str]] = None) -> None:
if args is None:
args = sys.argv[1:]
sources, options = process_options(args)
serious = False
errors = False

def flush_errors(a: List[str]) -> None:
nonlocal errors
if a:
errors = True
def flush_errors(a: List[str], serious: bool) -> None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you rename a to something longer?

f = sys.stderr if serious else sys.stdout
try:
for m in a:
f.write(m + '\n')
except BrokenPipeError:
pass
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd sys.exit(1) here.


serious = False
try:
res = type_check_only(sources, bin_dir, options, flush_errors)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer if the main body of test cases (mypy/test/testscheck.py) would use streaming errors (without testing that flushing happens as expected, i.e. we'd still only test that the sequence of errors in the output is as expected). Not sure if this is feasible without many changes to test cases. If not, at least it would be good to do a one-off manual check that streaming produces the same errors as not streaming in test cases.

a = res.errors
except CompileError as e:
a = e.messages
if not e.use_stdout:
serious = True
flush_errors(a[e.num_already_seen:])
if options.warn_unused_configs and options.unused_configs:
print("Warning: unused section(s) in %s: %s" %
(options.config_file,
Expand All @@ -91,7 +86,7 @@ def flush_errors(a: List[str]) -> None:
if options.junit_xml:
t1 = time.time()
util.write_junit_xml(t1 - t0, serious, a, options.junit_xml)
if errors:
if a:
sys.exit(1)


Expand Down Expand Up @@ -121,7 +116,7 @@ def readlinkabs(link: str) -> str:

def type_check_only(sources: List[BuildSource], bin_dir: Optional[str],
options: Options,
flush_errors: Optional[Callable[[List[str]], None]]) -> BuildResult:
flush_errors: Optional[Callable[[List[str], bool], None]]) -> BuildResult:
# Type-check the program and dependencies.
return build.build(sources=sources,
bin_dir=bin_dir,
Expand Down
36 changes: 21 additions & 15 deletions mypy/test/testerrorstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,36 +31,42 @@ def test_error_stream(testcase: DataDrivenTestCase) -> None:
options = Options()
options.show_traceback = True

a = []
logged_messages: List[str] = []
real_messages: List[str] = []

def flush_errors(msgs: List[str]) -> None:
nonlocal a
def flush_errors(msgs: List[str], serious: bool, is_real: bool=True) -> None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should be spaces around the = (it's a special case in PEP 8 :-).

if msgs:
a.append('==== Errors flushed ====')
a += msgs
logged_messages.append('==== Errors flushed ====')
logged_messages.extend(msgs)
if is_real:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block can also be indented.

real_messages.extend(msgs)

plugin = ChainedPlugin(options, [LoggingPlugin(options, flush_errors), DefaultPlugin(options)])
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style nit: Add empty line after nested function for clarity.


sources = [BuildSource('main', '__main__', '\n'.join(testcase.input))]
try:
build.build(sources=sources,
options=options,
alt_lib_path=test_temp_dir,
flush_errors=flush_errors,
plugin=plugin)
res = build.build(sources=sources,
options=options,
alt_lib_path=test_temp_dir,
flush_errors=flush_errors,
plugin=plugin)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only place where we pass a plugin to build(). I wish there was another way to do this... (It's pretty clever though. :-)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another approach would be arrange to load a plugin through the regular mechanisms---that requires the plugin communicating with the test via global state, though.

Another another approach is to ditch the plugin because the possible failure tests for (errors being streamed out properly but not until all typing is done) seems pretty marginal anyways.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm okay with ditching the plugin.

reported_messages = res.errors
except CompileError as e:
a.append('==== Blocking error ====')
a += e.messages[e.num_already_seen:]
reported_messages = e.messages

assert_string_arrays_equal(testcase.output, a,
assert_string_arrays_equal(testcase.output, logged_messages,
'Invalid output ({}, line {})'.format(
testcase.file, testcase.line))
assert_string_arrays_equal(reported_messages, real_messages,
'Streamed/reported mismatch ({}, line {})'.format(
testcase.file, testcase.line))


# Use a typechecking plugin to allow test cases to emit messages
# during typechecking. This allows us to verify that error messages
# from one SCC are printed before later ones are typechecked.
class LoggingPlugin(Plugin):
def __init__(self, options: Options, log: Callable[[List[str]], None]) -> None:
def __init__(self, options: Options, log: Callable[[List[str], bool, bool], None]) -> None:
super().__init__(options)
self.log = log

Expand All @@ -72,5 +78,5 @@ def get_function_hook(self, fullname: str) -> Optional[Callable[[FunctionContext
def hook(self, ctx: FunctionContext) -> Type:
assert(isinstance(ctx.context, CallExpr) and len(ctx.context.args) > 0 and
isinstance(ctx.context.args[0], StrExpr))
self.log([ctx.context.args[0].value])
self.log([ctx.context.args[0].value], False, False)
return ctx.default_return_type
28 changes: 27 additions & 1 deletion test-data/unit/errorstream.test
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,34 @@ import b
[file b.py]
import a
break
1 / '' # won't get reported, after a blocker
[out]
==== Errors flushed ====
a.py:1: error: Unsupported operand types for + ("int" and "str")
==== Blocking error ====
==== Errors flushed ====
b.py:2: error: 'break' outside loop

[case testCycles]
import a
[file a.py]
import b
1 + ''
def f() -> int:
reveal_type(b.x)
return b.x
y = 0 + 0
[file b.py]
import a
def g() -> int:
reveal_type(a.y)
return a.y
1 / ''
x = 1 + 1

[out]
==== Errors flushed ====
b.py:3: error: Revealed type is 'builtins.int'
b.py:5: error: Unsupported operand types for / ("int" and "str")
==== Errors flushed ====
a.py:2: error: Unsupported operand types for + ("int" and "str")
a.py:4: error: Revealed type is 'builtins.int'
0