From b3c1710dae9ad9e231139b4f405d4ee97f37b8ac Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 30 Aug 2019 11:28:28 +0100 Subject: [PATCH 1/6] Add support for nicer error output --- mypy/main.py | 16 ++++++++++ mypy/options.py | 4 +++ mypy/util.py | 81 +++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/mypy/main.py b/mypy/main.py index d7ebb7921fffc..08037c3034c63 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -63,12 +63,15 @@ def main(script_path: Optional[str], fscache=fscache) messages = [] + formatter = util.FancyFormatter(stdout, stderr, options.show_error_codes) def flush_errors(new_messages: List[str], serious: bool) -> None: messages.extend(new_messages) f = stderr if serious else stdout try: for msg in new_messages: + if options.color_output: + msg = formatter.colorize(msg) f.write(msg + '\n') f.flush() except BrokenPipeError: @@ -105,6 +108,13 @@ def flush_errors(new_messages: List[str], serious: bool) -> None: code = 0 if messages: code = 2 if blockers else 1 + if options.error_summary: + if messages: + n_errors, n_files = util.count_stats(messages) + stdout.write(formatter.format_error(n_errors, n_files) + '\n') + else: + stdout.write(formatter.format_success() + '\n') + stdout.flush() if options.fast_exit: # Exit without freeing objects -- it's faster. # @@ -568,6 +578,12 @@ def add_invertible_flag(flag: str, add_invertible_flag('--show-error-codes', default=False, help="Show error codes in error messages", group=error_group) + add_invertible_flag('--no-color-output', dest='color_output', default=True, + help="Do not colorize error messages", + group=error_group) + add_invertible_flag('--no-error-summary', dest='error_summary', default=True, + help="Do not show error stats summary", + group=error_group) strict_help = "Strict mode; enables the following flags: {}".format( ", ".join(strict_flag_names)) diff --git a/mypy/options.py b/mypy/options.py index d751597ba93dd..be95c29440dba 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -138,6 +138,10 @@ def __init__(self) -> None: # Show "note: In function "foo":" messages. self.show_error_context = False + # Use nicer output (when possible). + self.color_output = True + self.error_summary = True + # Files in which to allow strict-Optional related errors # TODO: Kill this in favor of show_none_errors self.strict_optional_whitelist = None # type: Optional[List[str]] diff --git a/mypy/util.py b/mypy/util.py index af8d1ef68e7b5..3cd98e682c465 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -4,9 +4,10 @@ import re import subprocess import sys +import curses -from typing import TypeVar, List, Tuple, Optional, Dict, Sequence, Iterable, Container -from typing_extensions import Final, Type +from typing import TypeVar, List, Tuple, Optional, Dict, Sequence, Iterable, Container, IO +from typing_extensions import Final, Type, Literal T = TypeVar('T') @@ -313,3 +314,79 @@ def check_python_version(program: str) -> None: if sys.version_info[:3] == (3, 5, 0): sys.exit("Running {name} with Python 3.5.0 is not supported; " "please upgrade to 3.5.1 or newer".format(name=program)) + + +def count_stats(errors: List[str]) -> Tuple[int, int]: + """Count total number of errors and files in error list.""" + errors = [e for e in errors if ': error:' in e] + files = {e.split(':')[0] for e in errors} + return len(errors), len(files) + + +class FancyFormatter: + """Apply color and bold font to terminal output. + + This currently only works on Linus an Mac. + """ + def __init__(self, f_out: IO[str], f_err: IO[str], + show_error_codes: bool) -> None: + self.show_error_codes = show_error_codes + # Check if we are in a human-facing terminal on a supported platform. + if sys.platform not in ('linux', 'darwin'): + self.dummy_term = True + return + if not f_out.isatty() or not f_err.isatty(): + self.dummy_term = True + return + + # We in a human-facing terminal, check if it supports enough styling. + curses.setupterm() + bold = curses.tigetstr('bold') + setaf = curses.tigetstr('setaf') + self.dummy_term = not (bold and setaf) + if self.dummy_term: + return + + self.BOLD = bold.decode() + self.BLUE = curses.tparm(setaf, curses.COLOR_BLUE).decode() + self.GREEN = curses.tparm(setaf, curses.COLOR_GREEN).decode() + self.RED = curses.tparm(setaf, curses.COLOR_RED).decode() + self.YELLOW = curses.tparm(setaf, curses.COLOR_YELLOW).decode() + self.NORMAL = curses.tigetstr('sgr0').decode() + self.colors = {'red': self.RED, 'green': self.GREEN, + 'blue': self.BLUE, 'yellow': self.YELLOW} + + def style(self, text: str, color: Literal['red', 'green', 'blue', 'yellow'], + bold: bool = False) -> str: + if self.dummy_term: + return text + if bold: + start = self.BOLD + else: + start = '' + return start + self.colors[color] + text + self.NORMAL + + def colorize(self, error: str) -> str: + """Colorize an output line by highlighting the status and error code.""" + if ': error:' in error: + loc, msg = error.split('error:', maxsplit=1) + if not self.show_error_codes: + return loc + self.style('error:', 'red') + msg + codepos = msg.rfind('[') + code = msg[codepos:] + msg = msg[:codepos] + return loc + self.style('error:', 'red') + msg + self.style(code, 'red', + bold=True) + elif ': note:' in error: + loc, msg = error.split('note:', maxsplit=1) + return loc + self.style('note:', 'blue') + msg + else: + return error + + def format_success(self) -> str: + return self.style('Success: no issues found', 'green', bold=True) + + def format_error(self, n_errors: int, n_files: int) -> str: + msg = 'Found {} error{} in {} file{}'.format(n_errors, 's' if n_errors > 1 else '', + n_files, 's' if n_files > 1 else '') + return self.style(msg, 'red', bold=True) From fdb9dbf4ca347c19b95234785a074a21db769087 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 30 Aug 2019 11:46:53 +0100 Subject: [PATCH 2/6] Be more careful about curses --- mypy/util.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/mypy/util.py b/mypy/util.py index 3cd98e682c465..bbdf5bed81edf 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -4,7 +4,6 @@ import re import subprocess import sys -import curses from typing import TypeVar, List, Tuple, Optional, Dict, Sequence, Iterable, Container, IO from typing_extensions import Final, Type, Literal @@ -340,7 +339,17 @@ def __init__(self, f_out: IO[str], f_err: IO[str], return # We in a human-facing terminal, check if it supports enough styling. - curses.setupterm() + try: + import curses + except ImportError: + self.dummy_term = True + return + try: + curses.setupterm() + except curses.error: + # Most likely terminfo not found. + self.dummy_term = True + return bold = curses.tigetstr('bold') setaf = curses.tigetstr('setaf') self.dummy_term = not (bold and setaf) From ffeb74ec1eee24d914f32111907e56799fd721de Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 30 Aug 2019 11:59:54 +0100 Subject: [PATCH 3/6] Mypyc doesn't like method level imports? --- mypy/util.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/mypy/util.py b/mypy/util.py index bbdf5bed81edf..52e150cc57b7e 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -8,6 +8,12 @@ from typing import TypeVar, List, Tuple, Optional, Dict, Sequence, Iterable, Container, IO from typing_extensions import Final, Type, Literal +try: + import curses + CURSES_ENABLED = True +except ImportError: + CURSES_ENABLED = False + T = TypeVar('T') ENCODING_RE = \ @@ -339,9 +345,7 @@ def __init__(self, f_out: IO[str], f_err: IO[str], return # We in a human-facing terminal, check if it supports enough styling. - try: - import curses - except ImportError: + if not CURSES_ENABLED: self.dummy_term = True return try: From 2d26fb06179f783881385b2e190d0bea4a6d6fa2 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 30 Aug 2019 12:42:05 +0100 Subject: [PATCH 4/6] Fix and add tests --- mypy/main.py | 3 +- mypy/test/helpers.py | 1 + mypy/test/testcmdline.py | 2 ++ mypy/test/testpep561.py | 1 + mypy/test/testpythoneval.py | 1 + mypy/util.py | 4 +-- test-data/unit/cmdline.test | 56 +++++++++++++++++++++++++++++++++++++ test-data/unit/daemon.test | 1 + 8 files changed, 66 insertions(+), 3 deletions(-) diff --git a/mypy/main.py b/mypy/main.py index 08037c3034c63..86631342ab24f 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -111,7 +111,8 @@ def flush_errors(new_messages: List[str], serious: bool) -> None: if options.error_summary: if messages: n_errors, n_files = util.count_stats(messages) - stdout.write(formatter.format_error(n_errors, n_files) + '\n') + if n_errors: + stdout.write(formatter.format_error(n_errors, n_files) + '\n') else: stdout.write(formatter.format_success() + '\n') stdout.flush() diff --git a/mypy/test/helpers.py b/mypy/test/helpers.py index dc0e4beea84b1..2a4b56d2230c0 100644 --- a/mypy/test/helpers.py +++ b/mypy/test/helpers.py @@ -387,6 +387,7 @@ def parse_options(program_text: str, testcase: DataDrivenTestCase, options = Options() # TODO: Enable strict optional in test cases by default (requires *many* test case changes) options.strict_optional = False + options.error_summary = False # Allow custom python version to override testcase_pyversion if (not flag_list or diff --git a/mypy/test/testcmdline.py b/mypy/test/testcmdline.py index 34981ee6cba6d..92129d3a78c86 100644 --- a/mypy/test/testcmdline.py +++ b/mypy/test/testcmdline.py @@ -47,6 +47,8 @@ def test_python_cmdline(testcase: DataDrivenTestCase, step: int) -> None: args = parse_args(testcase.input[0]) args.append('--show-traceback') args.append('--no-site-packages') + if '--error-summary' not in args: + args.append('--no-error-summary') # Type check the program. fixed = [python3_path, '-m', 'mypy'] env = os.environ.copy() diff --git a/mypy/test/testpep561.py b/mypy/test/testpep561.py index 77d38db00f1b5..f1976b295774e 100644 --- a/mypy/test/testpep561.py +++ b/mypy/test/testpep561.py @@ -114,6 +114,7 @@ def check_mypy_run(self, old_dir = os.getcwd() os.chdir(venv_dir) try: + cmd_line.append('--no-error-summary') if python_executable != sys.executable: cmd_line.append('--python-executable={}'.format(python_executable)) out, err, returncode = mypy.api.run(cmd_line) diff --git a/mypy/test/testpythoneval.py b/mypy/test/testpythoneval.py index 153eb4f78c7b4..b64fd2a28c610 100644 --- a/mypy/test/testpythoneval.py +++ b/mypy/test/testpythoneval.py @@ -57,6 +57,7 @@ def test_python_evaluation(testcase: DataDrivenTestCase, cache_dir: str) -> None '--no-site-packages', '--no-strict-optional', '--no-silence-site-packages', + '--no-error-summary', ] py2 = testcase.name.lower().endswith('python2') if py2: diff --git a/mypy/util.py b/mypy/util.py index 52e150cc57b7e..c741f9fd5ae16 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -400,6 +400,6 @@ def format_success(self) -> str: return self.style('Success: no issues found', 'green', bold=True) def format_error(self, n_errors: int, n_files: int) -> str: - msg = 'Found {} error{} in {} file{}'.format(n_errors, 's' if n_errors > 1 else '', - n_files, 's' if n_files > 1 else '') + msg = 'Found {} error{} in {} file{}'.format(n_errors, 's' if n_errors != 1 else '', + n_files, 's' if n_files != 1 else '') return self.style(msg, 'red', bold=True) diff --git a/test-data/unit/cmdline.test b/test-data/unit/cmdline.test index ce50419d58b45..cb02680620539 100644 --- a/test-data/unit/cmdline.test +++ b/test-data/unit/cmdline.test @@ -1331,3 +1331,59 @@ files = config.py [out] mypy: can't read file 'override.py': No such file or directory == Return code: 2 + +[case testErrorSummaryOnSuccess] +# cmd: mypy --error-summary good.py +[file good.py] +x = 2 + 2 +[out] +Success: no issues found +== Return code: 0 + +[case testErrorSummaryOnFail] +# cmd: mypy --error-summary bad.py +[file bad.py] +42 + 'no' +[out] +bad.py:1: error: Unsupported operand types for + ("int" and "str") +Found 1 error in 1 file + +[case testErrorSummaryOnFailNotes] +# cmd: mypy --error-summary bad.py +[file bad.py] +from typing import List +x: List[float] +y: List[int] +x = y +[out] +bad.py:4: error: Incompatible types in assignment (expression has type "List[int]", variable has type "List[float]") +bad.py:4: note: "List" is invariant -- see http://mypy.readthedocs.io/en/latest/common_issues.html#variance +bad.py:4: note: Consider using "Sequence" instead, which is covariant +Found 1 error in 1 file + +[case testErrorSummaryOnFailTwoErrors] +# cmd: mypy --error-summary bad.py +[file bad.py] +42 + 'no' +42 + 'no' +[out] +bad.py:1: error: Unsupported operand types for + ("int" and "str") +bad.py:2: error: Unsupported operand types for + ("int" and "str") +Found 2 errors in 1 file + +[case testErrorSummaryOnFailTwoFiles] +# cmd: mypy --error-summary bad.py bad2.py +[file bad.py] +42 + 'no' +[file bad2.py] +42 + 'no' +[out] +bad2.py:1: error: Unsupported operand types for + ("int" and "str") +bad.py:1: error: Unsupported operand types for + ("int" and "str") +Found 2 errors in 2 files + +[case testErrorSummaryOnBadUsage] +# cmd: mypy --error-summary missing.py +[out] +mypy: can't read file 'missing.py': No such file or directory +== Return code: 2 diff --git a/test-data/unit/daemon.test b/test-data/unit/daemon.test index df0d4a0e8c0bf..6ccce0697c843 100644 --- a/test-data/unit/daemon.test +++ b/test-data/unit/daemon.test @@ -146,6 +146,7 @@ Daemon stopped $ {python} -c "print('x=1')" >foo.py $ {python} -c "print('x=1')" >bar.py $ mypy --local-partial-types --cache-fine-grained --follow-imports=error --no-sqlite-cache --python-version=3.6 -- foo.py bar.py +Success: no issues found $ {python} -c "import shutil; shutil.copy('.mypy_cache/3.6/bar.meta.json', 'asdf.json')" -- update bar's timestamp but don't change the file $ {python} -c "import time;time.sleep(1)" From bf3d3db2dced78ea1d02b95d23651efb1a18ce84 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 30 Aug 2019 16:35:25 +0100 Subject: [PATCH 5/6] Address CR; few more ideas --- mypy/main.py | 4 +-- mypy/util.py | 72 +++++++++++++++++++++++++++---------- test-data/unit/cmdline.test | 18 +++++----- test-data/unit/daemon.test | 2 +- 4 files changed, 65 insertions(+), 31 deletions(-) diff --git a/mypy/main.py b/mypy/main.py index 86631342ab24f..bcc165a8bc4ce 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -112,9 +112,9 @@ def flush_errors(new_messages: List[str], serious: bool) -> None: if messages: n_errors, n_files = util.count_stats(messages) if n_errors: - stdout.write(formatter.format_error(n_errors, n_files) + '\n') + stdout.write(formatter.format_error(n_errors, n_files, len(sources)) + '\n') else: - stdout.write(formatter.format_success() + '\n') + stdout.write(formatter.format_success(len(sources)) + '\n') stdout.flush() if options.fast_exit: # Exit without freeing objects -- it's faster. diff --git a/mypy/util.py b/mypy/util.py index c741f9fd5ae16..69181e3307ba5 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -10,6 +10,7 @@ try: import curses + import _curses # noqa CURSES_ENABLED = True except ImportError: CURSES_ENABLED = False @@ -355,28 +356,33 @@ def __init__(self, f_out: IO[str], f_err: IO[str], self.dummy_term = True return bold = curses.tigetstr('bold') - setaf = curses.tigetstr('setaf') - self.dummy_term = not (bold and setaf) + under = curses.tigetstr('smul') + set_color = curses.tigetstr('setaf') + self.dummy_term = not (bold and under and set_color) if self.dummy_term: return self.BOLD = bold.decode() - self.BLUE = curses.tparm(setaf, curses.COLOR_BLUE).decode() - self.GREEN = curses.tparm(setaf, curses.COLOR_GREEN).decode() - self.RED = curses.tparm(setaf, curses.COLOR_RED).decode() - self.YELLOW = curses.tparm(setaf, curses.COLOR_YELLOW).decode() + self.UNDER = under.decode() + self.BLUE = curses.tparm(set_color, curses.COLOR_BLUE).decode() + self.GREEN = curses.tparm(set_color, curses.COLOR_GREEN).decode() + self.RED = curses.tparm(set_color, curses.COLOR_RED).decode() + self.YELLOW = curses.tparm(set_color, curses.COLOR_YELLOW).decode() self.NORMAL = curses.tigetstr('sgr0').decode() self.colors = {'red': self.RED, 'green': self.GREEN, - 'blue': self.BLUE, 'yellow': self.YELLOW} + 'blue': self.BLUE, 'yellow': self.YELLOW, + 'none': ''} - def style(self, text: str, color: Literal['red', 'green', 'blue', 'yellow'], - bold: bool = False) -> str: + def style(self, text: str, color: Literal['red', 'green', 'blue', 'yellow', 'none'], + bold: bool = False, underline: bool = False) -> str: if self.dummy_term: return text if bold: start = self.BOLD else: start = '' + if underline: + start += self.UNDER return start + self.colors[color] + text + self.NORMAL def colorize(self, error: str) -> str: @@ -384,22 +390,50 @@ def colorize(self, error: str) -> str: if ': error:' in error: loc, msg = error.split('error:', maxsplit=1) if not self.show_error_codes: - return loc + self.style('error:', 'red') + msg + return (loc + self.style('error:', 'red', bold=True) + + self.highlight_quote_groups(msg)) codepos = msg.rfind('[') code = msg[codepos:] msg = msg[:codepos] - return loc + self.style('error:', 'red') + msg + self.style(code, 'red', - bold=True) + return (loc + self.style('error:', 'red', bold=True) + + self.highlight_quote_groups(msg) + self.style(code, 'yellow')) elif ': note:' in error: loc, msg = error.split('note:', maxsplit=1) - return loc + self.style('note:', 'blue') + msg + return loc + self.style('note:', 'blue') + self.underline_link(msg) else: return error - def format_success(self) -> str: - return self.style('Success: no issues found', 'green', bold=True) - - def format_error(self, n_errors: int, n_files: int) -> str: - msg = 'Found {} error{} in {} file{}'.format(n_errors, 's' if n_errors != 1 else '', - n_files, 's' if n_files != 1 else '') + def highlight_quote_groups(self, msg: str) -> str: + if msg.count('"') % 2: + # Broken error message, don't do any formatting. + return msg + parts = msg.split('"') + out = '' + for i, part in enumerate(parts): + if i % 2 == 0: + out += self.style(part, 'none') + else: + out += self.style('"' + part + '"', 'none', bold=True) + return out + + def underline_link(self, note: str) -> str: + match = re.search(r'http://\S*', note) + if not match: + return note + start = match.start() + end = match.end() + return (note[:start] + + self.style(note[start:end], 'none', underline=True) + + note[end:]) + + def format_success(self, n_sources: int) -> str: + return self.style('Success: no issues found in {}' + ' source file{}'.format(n_sources, 's' if n_sources != 1 else ''), + 'green', bold=True) + + def format_error(self, n_errors: int, n_files: int, n_sources: int) -> str: + msg = 'Found {} error{} in {} file{}' \ + ' (checked {} source file{})'.format(n_errors, 's' if n_errors != 1 else '', + n_files, 's' if n_files != 1 else '', + n_sources, 's' if n_sources != 1 else '') return self.style(msg, 'red', bold=True) diff --git a/test-data/unit/cmdline.test b/test-data/unit/cmdline.test index cb02680620539..8a75579ff119c 100644 --- a/test-data/unit/cmdline.test +++ b/test-data/unit/cmdline.test @@ -1322,7 +1322,6 @@ fail a/b.py:1: error: Name 'fail' is not defined c.py:1: error: Name 'fail' is not defined - [case testIniFilesCmdlineOverridesConfig] # cmd: mypy override.py [file mypy.ini] @@ -1337,7 +1336,7 @@ mypy: can't read file 'override.py': No such file or directory [file good.py] x = 2 + 2 [out] -Success: no issues found +Success: no issues found in 1 source file == Return code: 0 [case testErrorSummaryOnFail] @@ -1346,30 +1345,31 @@ Success: no issues found 42 + 'no' [out] bad.py:1: error: Unsupported operand types for + ("int" and "str") -Found 1 error in 1 file +Found 1 error in 1 file (checked 1 source file) [case testErrorSummaryOnFailNotes] # cmd: mypy --error-summary bad.py [file bad.py] from typing import List -x: List[float] -y: List[int] +x = [] # type: List[float] +y = [] # type: List[int] x = y [out] bad.py:4: error: Incompatible types in assignment (expression has type "List[int]", variable has type "List[float]") bad.py:4: note: "List" is invariant -- see http://mypy.readthedocs.io/en/latest/common_issues.html#variance bad.py:4: note: Consider using "Sequence" instead, which is covariant -Found 1 error in 1 file +Found 1 error in 1 file (checked 1 source file) [case testErrorSummaryOnFailTwoErrors] -# cmd: mypy --error-summary bad.py +# cmd: mypy --error-summary bad.py foo.py [file bad.py] 42 + 'no' 42 + 'no' +[file foo.py] [out] bad.py:1: error: Unsupported operand types for + ("int" and "str") bad.py:2: error: Unsupported operand types for + ("int" and "str") -Found 2 errors in 1 file +Found 2 errors in 1 file (checked 2 source files) [case testErrorSummaryOnFailTwoFiles] # cmd: mypy --error-summary bad.py bad2.py @@ -1380,7 +1380,7 @@ Found 2 errors in 1 file [out] bad2.py:1: error: Unsupported operand types for + ("int" and "str") bad.py:1: error: Unsupported operand types for + ("int" and "str") -Found 2 errors in 2 files +Found 2 errors in 2 files (checked 2 source files) [case testErrorSummaryOnBadUsage] # cmd: mypy --error-summary missing.py diff --git a/test-data/unit/daemon.test b/test-data/unit/daemon.test index 6ccce0697c843..84db6b9cf4cc0 100644 --- a/test-data/unit/daemon.test +++ b/test-data/unit/daemon.test @@ -146,7 +146,7 @@ Daemon stopped $ {python} -c "print('x=1')" >foo.py $ {python} -c "print('x=1')" >bar.py $ mypy --local-partial-types --cache-fine-grained --follow-imports=error --no-sqlite-cache --python-version=3.6 -- foo.py bar.py -Success: no issues found +Success: no issues found in 2 source files $ {python} -c "import shutil; shutil.copy('.mypy_cache/3.6/bar.meta.json', 'asdf.json')" -- update bar's timestamp but don't change the file $ {python} -c "import time;time.sleep(1)" From 1ed664020163f73f11cd5890778b84a4a967a784 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sat, 31 Aug 2019 00:37:31 +0100 Subject: [PATCH 6/6] Address CR --- mypy/util.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mypy/util.py b/mypy/util.py index 69181e3307ba5..6fb297c94262a 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -332,7 +332,7 @@ def count_stats(errors: List[str]) -> Tuple[int, int]: class FancyFormatter: """Apply color and bold font to terminal output. - This currently only works on Linus an Mac. + This currently only works on Linux and Mac. """ def __init__(self, f_out: IO[str], f_err: IO[str], show_error_codes: bool) -> None: @@ -341,7 +341,8 @@ def __init__(self, f_out: IO[str], f_err: IO[str], if sys.platform not in ('linux', 'darwin'): self.dummy_term = True return - if not f_out.isatty() or not f_err.isatty(): + force_color = int(os.getenv('MYPY_FORCE_COLOR', '0')) + if not force_color and (not f_out.isatty() or not f_err.isatty()): self.dummy_term = True return @@ -417,7 +418,7 @@ def highlight_quote_groups(self, msg: str) -> str: return out def underline_link(self, note: str) -> str: - match = re.search(r'http://\S*', note) + match = re.search(r'https?://\S*', note) if not match: return note start = match.start()