1"""Utility functions with no non-trivial dependencies."""
2
3import os
4import pathlib
5import re
6import subprocess
7import sys
8import hashlib
9import io
10import shutil
11
12from typing import (
13    TypeVar, List, Tuple, Optional, Dict, Sequence, Iterable, Container, IO, Callable
14)
15from typing_extensions import Final, Type, Literal
16
17try:
18    import curses
19    import _curses  # noqa
20    CURSES_ENABLED = True
21except ImportError:
22    CURSES_ENABLED = False
23
24T = TypeVar('T')
25
26ENCODING_RE = \
27    re.compile(br'([ \t\v]*#.*(\r\n?|\n))??[ \t\v]*#.*coding[:=][ \t]*([-\w.]+)')  # type: Final
28
29DEFAULT_SOURCE_OFFSET = 4  # type: Final
30DEFAULT_COLUMNS = 80  # type: Final
31
32# At least this number of columns will be shown on each side of
33# error location when printing source code snippet.
34MINIMUM_WIDTH = 20
35
36# VT100 color code processing was added in Windows 10, but only the second major update,
37# Threshold 2. Fortunately, everyone (even on LTSB, Long Term Support Branch) should
38# have a version of Windows 10 newer than this. Note that Windows 8 and below are not
39# supported, but are either going out of support, or make up only a few % of the market.
40MINIMUM_WINDOWS_MAJOR_VT100 = 10
41MINIMUM_WINDOWS_BUILD_VT100 = 10586
42
43default_python2_interpreter = \
44    ['python2', 'python', '/usr/bin/python', 'C:\\Python27\\python.exe']  # type: Final
45
46
47def split_module_names(mod_name: str) -> List[str]:
48    """Return the module and all parent module names.
49
50    So, if `mod_name` is 'a.b.c', this function will return
51    ['a.b.c', 'a.b', and 'a'].
52    """
53    out = [mod_name]
54    while '.' in mod_name:
55        mod_name = mod_name.rsplit('.', 1)[0]
56        out.append(mod_name)
57    return out
58
59
60def module_prefix(modules: Iterable[str], target: str) -> Optional[str]:
61    result = split_target(modules, target)
62    if result is None:
63        return None
64    return result[0]
65
66
67def split_target(modules: Iterable[str], target: str) -> Optional[Tuple[str, str]]:
68    remaining = []  # type: List[str]
69    while True:
70        if target in modules:
71            return target, '.'.join(remaining)
72        components = target.rsplit('.', 1)
73        if len(components) == 1:
74            return None
75        target = components[0]
76        remaining.insert(0, components[1])
77
78
79def short_type(obj: object) -> str:
80    """Return the last component of the type name of an object.
81
82    If obj is None, return 'nil'. For example, if obj is 1, return 'int'.
83    """
84    if obj is None:
85        return 'nil'
86    t = str(type(obj))
87    return t.split('.')[-1].rstrip("'>")
88
89
90def find_python_encoding(text: bytes, pyversion: Tuple[int, int]) -> Tuple[str, int]:
91    """PEP-263 for detecting Python file encoding"""
92    result = ENCODING_RE.match(text)
93    if result:
94        line = 2 if result.group(1) else 1
95        encoding = result.group(3).decode('ascii')
96        # Handle some aliases that Python is happy to accept and that are used in the wild.
97        if encoding.startswith(('iso-latin-1-', 'latin-1-')) or encoding == 'iso-latin-1':
98            encoding = 'latin-1'
99        return encoding, line
100    else:
101        default_encoding = 'utf8' if pyversion[0] >= 3 else 'ascii'
102        return default_encoding, -1
103
104
105class DecodeError(Exception):
106    """Exception raised when a file cannot be decoded due to an unknown encoding type.
107
108    Essentially a wrapper for the LookupError raised by `bytearray.decode`
109    """
110
111
112def decode_python_encoding(source: bytes, pyversion: Tuple[int, int]) -> str:
113    """Read the Python file with while obeying PEP-263 encoding detection.
114
115    Returns the source as a string.
116    """
117    # check for BOM UTF-8 encoding and strip it out if present
118    if source.startswith(b'\xef\xbb\xbf'):
119        encoding = 'utf8'
120        source = source[3:]
121    else:
122        # look at first two lines and check if PEP-263 coding is present
123        encoding, _ = find_python_encoding(source, pyversion)
124
125    try:
126        source_text = source.decode(encoding)
127    except LookupError as lookuperr:
128        raise DecodeError(str(lookuperr)) from lookuperr
129    return source_text
130
131
132def read_py_file(path: str, read: Callable[[str], bytes],
133                 pyversion: Tuple[int, int]) -> Optional[List[str]]:
134    """Try reading a Python file as list of source lines.
135
136    Return None if something goes wrong.
137    """
138    try:
139        source = read(path)
140    except OSError:
141        return None
142    else:
143        try:
144            source_lines = decode_python_encoding(source, pyversion).splitlines()
145        except DecodeError:
146            return None
147        return source_lines
148
149
150def trim_source_line(line: str, max_len: int, col: int, min_width: int) -> Tuple[str, int]:
151    """Trim a line of source code to fit into max_len.
152
153    Show 'min_width' characters on each side of 'col' (an error location). If either
154    start or end is trimmed, this is indicated by adding '...' there.
155    A typical result looks like this:
156        ...some_variable = function_to_call(one_arg, other_arg) or...
157
158    Return the trimmed string and the column offset to to adjust error location.
159    """
160    if max_len < 2 * min_width + 1:
161        # In case the window is too tiny it is better to still show something.
162        max_len = 2 * min_width + 1
163
164    # Trivial case: line already fits in.
165    if len(line) <= max_len:
166        return line, 0
167
168    # If column is not too large so that there is still min_width after it,
169    # the line doesn't need to be trimmed at the start.
170    if col + min_width < max_len:
171        return line[:max_len] + '...', 0
172
173    # Otherwise, if the column is not too close to the end, trim both sides.
174    if col < len(line) - min_width - 1:
175        offset = col - max_len + min_width + 1
176        return '...' + line[offset:col + min_width + 1] + '...', offset - 3
177
178    # Finally, if the column is near the end, just trim the start.
179    return '...' + line[-max_len:], len(line) - max_len - 3
180
181
182def get_mypy_comments(source: str) -> List[Tuple[int, str]]:
183    PREFIX = '# mypy: '
184    # Don't bother splitting up the lines unless we know it is useful
185    if PREFIX not in source:
186        return []
187    lines = source.split('\n')
188    results = []
189    for i, line in enumerate(lines):
190        if line.startswith(PREFIX):
191            results.append((i + 1, line[len(PREFIX):]))
192
193    return results
194
195
196_python2_interpreter = None  # type: Optional[str]
197
198
199def try_find_python2_interpreter() -> Optional[str]:
200    global _python2_interpreter
201    if _python2_interpreter:
202        return _python2_interpreter
203    for interpreter in default_python2_interpreter:
204        try:
205            retcode = subprocess.Popen([
206                interpreter, '-c',
207                'import sys, typing; assert sys.version_info[:2] == (2, 7)'
208            ]).wait()
209            if not retcode:
210                _python2_interpreter = interpreter
211                return interpreter
212        except OSError:
213            pass
214    return None
215
216
217PASS_TEMPLATE = """<?xml version="1.0" encoding="utf-8"?>
218<testsuite errors="0" failures="0" name="mypy" skips="0" tests="1" time="{time:.3f}">
219  <testcase classname="mypy" file="mypy" line="1" name="mypy-py{ver}-{platform}" time="{time:.3f}">
220  </testcase>
221</testsuite>
222"""  # type: Final
223
224FAIL_TEMPLATE = """<?xml version="1.0" encoding="utf-8"?>
225<testsuite errors="0" failures="1" name="mypy" skips="0" tests="1" time="{time:.3f}">
226  <testcase classname="mypy" file="mypy" line="1" name="mypy-py{ver}-{platform}" time="{time:.3f}">
227    <failure message="mypy produced messages">{text}</failure>
228  </testcase>
229</testsuite>
230"""  # type: Final
231
232ERROR_TEMPLATE = """<?xml version="1.0" encoding="utf-8"?>
233<testsuite errors="1" failures="0" name="mypy" skips="0" tests="1" time="{time:.3f}">
234  <testcase classname="mypy" file="mypy" line="1" name="mypy-py{ver}-{platform}" time="{time:.3f}">
235    <error message="mypy produced errors">{text}</error>
236  </testcase>
237</testsuite>
238"""  # type: Final
239
240
241def write_junit_xml(dt: float, serious: bool, messages: List[str], path: str,
242                    version: str, platform: str) -> None:
243    from xml.sax.saxutils import escape
244    if not messages and not serious:
245        xml = PASS_TEMPLATE.format(time=dt, ver=version, platform=platform)
246    elif not serious:
247        xml = FAIL_TEMPLATE.format(text=escape('\n'.join(messages)), time=dt,
248                                   ver=version, platform=platform)
249    else:
250        xml = ERROR_TEMPLATE.format(text=escape('\n'.join(messages)), time=dt,
251                                    ver=version, platform=platform)
252
253    # checks for a directory structure in path and creates folders if needed
254    xml_dirs = os.path.dirname(os.path.abspath(path))
255    if not os.path.isdir(xml_dirs):
256        os.makedirs(xml_dirs)
257
258    with open(path, 'wb') as f:
259        f.write(xml.encode('utf-8'))
260
261
262class IdMapper:
263    """Generate integer ids for objects.
264
265    Unlike id(), these start from 0 and increment by 1, and ids won't
266    get reused across the life-time of IdMapper.
267
268    Assume objects don't redefine __eq__ or __hash__.
269    """
270
271    def __init__(self) -> None:
272        self.id_map = {}  # type: Dict[object, int]
273        self.next_id = 0
274
275    def id(self, o: object) -> int:
276        if o not in self.id_map:
277            self.id_map[o] = self.next_id
278            self.next_id += 1
279        return self.id_map[o]
280
281
282def get_prefix(fullname: str) -> str:
283    """Drop the final component of a qualified name (e.g. ('x.y' -> 'x')."""
284    return fullname.rsplit('.', 1)[0]
285
286
287def get_top_two_prefixes(fullname: str) -> Tuple[str, str]:
288    """Return one and two component prefixes of a fully qualified name.
289
290    Given 'a.b.c.d', return ('a', 'a.b').
291
292    If fullname has only one component, return (fullname, fullname).
293    """
294    components = fullname.split('.', 3)
295    return components[0], '.'.join(components[:2])
296
297
298def correct_relative_import(cur_mod_id: str,
299                            relative: int,
300                            target: str,
301                            is_cur_package_init_file: bool) -> Tuple[str, bool]:
302    if relative == 0:
303        return target, True
304    parts = cur_mod_id.split(".")
305    rel = relative
306    if is_cur_package_init_file:
307        rel -= 1
308    ok = len(parts) >= rel
309    if rel != 0:
310        cur_mod_id = ".".join(parts[:-rel])
311    return cur_mod_id + (("." + target) if target else ""), ok
312
313
314fields_cache = {}  # type: Final[Dict[Type[object], List[str]]]
315
316
317def get_class_descriptors(cls: 'Type[object]') -> Sequence[str]:
318    import inspect  # Lazy import for minor startup speed win
319    # Maintain a cache of type -> attributes defined by descriptors in the class
320    # (that is, attributes from __slots__ and C extension classes)
321    if cls not in fields_cache:
322        members = inspect.getmembers(
323            cls,
324            lambda o: inspect.isgetsetdescriptor(o) or inspect.ismemberdescriptor(o))
325        fields_cache[cls] = [x for x, y in members if x != '__weakref__' and x != '__dict__']
326    return fields_cache[cls]
327
328
329def replace_object_state(new: object, old: object, copy_dict: bool = False) -> None:
330    """Copy state of old node to the new node.
331
332    This handles cases where there is __dict__ and/or attribute descriptors
333    (either from slots or because the type is defined in a C extension module).
334
335    Assume that both objects have the same __class__.
336    """
337    if hasattr(old, '__dict__'):
338        if copy_dict:
339            new.__dict__ = dict(old.__dict__)
340        else:
341            new.__dict__ = old.__dict__
342
343    for attr in get_class_descriptors(old.__class__):
344        try:
345            if hasattr(old, attr):
346                setattr(new, attr, getattr(old, attr))
347            elif hasattr(new, attr):
348                delattr(new, attr)
349        # There is no way to distinguish getsetdescriptors that allow
350        # writes from ones that don't (I think?), so we just ignore
351        # AttributeErrors if we need to.
352        # TODO: What about getsetdescriptors that act like properties???
353        except AttributeError:
354            pass
355
356
357def is_sub_path(path1: str, path2: str) -> bool:
358    """Given two paths, return if path1 is a sub-path of path2."""
359    return pathlib.Path(path2) in pathlib.Path(path1).parents
360
361
362def hard_exit(status: int = 0) -> None:
363    """Kill the current process without fully cleaning up.
364
365    This can be quite a bit faster than a normal exit() since objects are not freed.
366    """
367    sys.stdout.flush()
368    sys.stderr.flush()
369    os._exit(status)
370
371
372def unmangle(name: str) -> str:
373    """Remove internal suffixes from a short name."""
374    return name.rstrip("'")
375
376
377def get_unique_redefinition_name(name: str, existing: Container[str]) -> str:
378    """Get a simple redefinition name not present among existing.
379
380    For example, for name 'foo' we try 'foo-redefinition', 'foo-redefinition2',
381    'foo-redefinition3', etc. until we find one that is not in existing.
382    """
383    r_name = name + '-redefinition'
384    if r_name not in existing:
385        return r_name
386
387    i = 2
388    while r_name + str(i) in existing:
389        i += 1
390    return r_name + str(i)
391
392
393def check_python_version(program: str) -> None:
394    """Report issues with the Python used to run mypy, dmypy, or stubgen"""
395    # Check for known bad Python versions.
396    if sys.version_info[:2] < (3, 5):
397        sys.exit("Running {name} with Python 3.4 or lower is not supported; "
398                 "please upgrade to 3.5 or newer".format(name=program))
399    # this can be deleted once we drop support for 3.5
400    if sys.version_info[:3] == (3, 5, 0):
401        sys.exit("Running {name} with Python 3.5.0 is not supported; "
402                 "please upgrade to 3.5.1 or newer".format(name=program))
403
404
405def count_stats(errors: List[str]) -> Tuple[int, int]:
406    """Count total number of errors and files in error list."""
407    errors = [e for e in errors if ': error:' in e]
408    files = {e.split(':')[0] for e in errors}
409    return len(errors), len(files)
410
411
412def split_words(msg: str) -> List[str]:
413    """Split line of text into words (but not within quoted groups)."""
414    next_word = ''
415    res = []  # type: List[str]
416    allow_break = True
417    for c in msg:
418        if c == ' ' and allow_break:
419            res.append(next_word)
420            next_word = ''
421            continue
422        if c == '"':
423            allow_break = not allow_break
424        next_word += c
425    res.append(next_word)
426    return res
427
428
429def get_terminal_width() -> int:
430    """Get current terminal width if possible, otherwise return the default one."""
431    return (int(os.getenv('MYPY_FORCE_TERMINAL_WIDTH', '0'))
432            or shutil.get_terminal_size().columns
433            or DEFAULT_COLUMNS)
434
435
436def soft_wrap(msg: str, max_len: int, first_offset: int,
437              num_indent: int = 0) -> str:
438    """Wrap a long error message into few lines.
439
440    Breaks will only happen between words, and never inside a quoted group
441    (to avoid breaking types such as "Union[int, str]"). The 'first_offset' is
442    the width before the start of first line.
443
444    Pad every next line with 'num_indent' spaces. Every line will be at most 'max_len'
445    characters, except if it is a single word or quoted group.
446
447    For example:
448               first_offset
449        ------------------------
450        path/to/file: error: 58: Some very long error message
451            that needs to be split in separate lines.
452            "Long[Type, Names]" are never split.
453        ^^^^--------------------------------------------------
454        num_indent           max_len
455    """
456    words = split_words(msg)
457    next_line = words.pop(0)
458    lines = []  # type: List[str]
459    while words:
460        next_word = words.pop(0)
461        max_line_len = max_len - num_indent if lines else max_len - first_offset
462        # Add 1 to account for space between words.
463        if len(next_line) + len(next_word) + 1 <= max_line_len:
464            next_line += ' ' + next_word
465        else:
466            lines.append(next_line)
467            next_line = next_word
468    lines.append(next_line)
469    padding = '\n' + ' ' * num_indent
470    return padding.join(lines)
471
472
473def hash_digest(data: bytes) -> str:
474    """Compute a hash digest of some data.
475
476    We use a cryptographic hash because we want a low probability of
477    accidental collision, but we don't really care about any of the
478    cryptographic properties.
479    """
480    # Once we drop Python 3.5 support, we should consider using
481    # blake2b, which is faster.
482    return hashlib.sha256(data).hexdigest()
483
484
485def parse_gray_color(cup: bytes) -> str:
486    """Reproduce a gray color in ANSI escape sequence"""
487    set_color = ''.join([cup[:-1].decode(), 'm'])
488    gray = curses.tparm(set_color.encode('utf-8'), 1, 89).decode()
489    return gray
490
491
492class FancyFormatter:
493    """Apply color and bold font to terminal output.
494
495    This currently only works on Linux and Mac.
496    """
497    def __init__(self, f_out: IO[str], f_err: IO[str], show_error_codes: bool) -> None:
498        self.show_error_codes = show_error_codes
499        # Check if we are in a human-facing terminal on a supported platform.
500        if sys.platform not in ('linux', 'darwin', 'win32'):
501            self.dummy_term = True
502            return
503        force_color = int(os.getenv('MYPY_FORCE_COLOR', '0'))
504        if not force_color and (not f_out.isatty() or not f_err.isatty()):
505            self.dummy_term = True
506            return
507        if sys.platform == 'win32':
508            self.dummy_term = not self.initialize_win_colors()
509        else:
510            self.dummy_term = not self.initialize_unix_colors()
511        if not self.dummy_term:
512            self.colors = {'red': self.RED, 'green': self.GREEN,
513                           'blue': self.BLUE, 'yellow': self.YELLOW,
514                           'none': ''}
515
516    def initialize_win_colors(self) -> bool:
517        """Return True if initialization was successful and we can use colors, False otherwise"""
518        # Windows ANSI escape sequences are only supported on Threshold 2 and above.
519        # we check with an assert at runtime and an if check for mypy, as asserts do not
520        # yet narrow platform
521        assert sys.platform == 'win32'
522        if sys.platform == 'win32':
523            winver = sys.getwindowsversion()
524            if (winver.major < MINIMUM_WINDOWS_MAJOR_VT100
525            or winver.build < MINIMUM_WINDOWS_BUILD_VT100):
526                return False
527            import ctypes
528            kernel32 = ctypes.windll.kernel32
529            ENABLE_PROCESSED_OUTPUT = 0x1
530            ENABLE_WRAP_AT_EOL_OUTPUT = 0x2
531            ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x4
532            STD_OUTPUT_HANDLE = -11
533            kernel32.SetConsoleMode(kernel32.GetStdHandle(STD_OUTPUT_HANDLE),
534                                    ENABLE_PROCESSED_OUTPUT
535                                    | ENABLE_WRAP_AT_EOL_OUTPUT
536                                    | ENABLE_VIRTUAL_TERMINAL_PROCESSING)
537            self.BOLD = '\033[1m'
538            self.UNDER = '\033[4m'
539            self.BLUE = '\033[94m'
540            self.GREEN = '\033[92m'
541            self.RED = '\033[91m'
542            self.YELLOW = '\033[93m'
543            self.NORMAL = '\033[0m'
544            self.DIM = '\033[2m'
545            return True
546        return False
547
548    def initialize_unix_colors(self) -> bool:
549        """Return True if initialization was successful and we can use colors, False otherwise"""
550        if not CURSES_ENABLED:
551            return False
552        try:
553            # setupterm wants a fd to potentially write an "initialization sequence".
554            # We override sys.stdout for the daemon API so if stdout doesn't have an fd,
555            # just give it /dev/null.
556            try:
557                fd = sys.stdout.fileno()
558            except io.UnsupportedOperation:
559                with open("/dev/null", "rb") as f:
560                    curses.setupterm(fd=f.fileno())
561            else:
562                curses.setupterm(fd=fd)
563        except curses.error:
564            # Most likely terminfo not found.
565            return False
566        bold = curses.tigetstr('bold')
567        under = curses.tigetstr('smul')
568        set_color = curses.tigetstr('setaf')
569        set_eseq = curses.tigetstr('cup')
570
571        if not (bold and under and set_color and set_eseq):
572            return False
573
574        self.NORMAL = curses.tigetstr('sgr0').decode()
575        self.BOLD = bold.decode()
576        self.UNDER = under.decode()
577        self.DIM = parse_gray_color(set_eseq)
578        self.BLUE = curses.tparm(set_color, curses.COLOR_BLUE).decode()
579        self.GREEN = curses.tparm(set_color, curses.COLOR_GREEN).decode()
580        self.RED = curses.tparm(set_color, curses.COLOR_RED).decode()
581        self.YELLOW = curses.tparm(set_color, curses.COLOR_YELLOW).decode()
582        return True
583
584    def style(self, text: str, color: Literal['red', 'green', 'blue', 'yellow', 'none'],
585              bold: bool = False, underline: bool = False, dim: bool = False) -> str:
586        """Apply simple color and style (underlined or bold)."""
587        if self.dummy_term:
588            return text
589        if bold:
590            start = self.BOLD
591        else:
592            start = ''
593        if underline:
594            start += self.UNDER
595        if dim:
596            start += self.DIM
597        return start + self.colors[color] + text + self.NORMAL
598
599    def fit_in_terminal(self, messages: List[str],
600                        fixed_terminal_width: Optional[int] = None) -> List[str]:
601        """Improve readability by wrapping error messages and trimming source code."""
602        width = fixed_terminal_width or get_terminal_width()
603        new_messages = messages.copy()
604        for i, error in enumerate(messages):
605            if ': error:' in error:
606                loc, msg = error.split('error:', maxsplit=1)
607                msg = soft_wrap(msg, width, first_offset=len(loc) + len('error: '))
608                new_messages[i] = loc + 'error:' + msg
609            if error.startswith(' ' * DEFAULT_SOURCE_OFFSET) and '^' not in error:
610                # TODO: detecting source code highlights through an indent can be surprising.
611                # Restore original error message and error location.
612                error = error[DEFAULT_SOURCE_OFFSET:]
613                column = messages[i+1].index('^') - DEFAULT_SOURCE_OFFSET
614
615                # Let source have some space also on the right side, plus 6
616                # to accommodate ... on each side.
617                max_len = width - DEFAULT_SOURCE_OFFSET - 6
618                source_line, offset = trim_source_line(error, max_len, column, MINIMUM_WIDTH)
619
620                new_messages[i] = ' ' * DEFAULT_SOURCE_OFFSET + source_line
621                # Also adjust the error marker position.
622                new_messages[i+1] = ' ' * (DEFAULT_SOURCE_OFFSET + column - offset) + '^'
623        return new_messages
624
625    def colorize(self, error: str) -> str:
626        """Colorize an output line by highlighting the status and error code."""
627        if ': error:' in error:
628            loc, msg = error.split('error:', maxsplit=1)
629            if not self.show_error_codes:
630                return (loc + self.style('error:', 'red', bold=True) +
631                        self.highlight_quote_groups(msg))
632            codepos = msg.rfind('[')
633            if codepos != -1:
634                code = msg[codepos:]
635                msg = msg[:codepos]
636            else:
637                code = ""  # no error code specified
638            return (loc + self.style('error:', 'red', bold=True) +
639                    self.highlight_quote_groups(msg) + self.style(code, 'yellow'))
640        elif ': note:' in error:
641            loc, msg = error.split('note:', maxsplit=1)
642            formatted = self.highlight_quote_groups(self.underline_link(msg))
643            return loc + self.style('note:', 'blue') + formatted
644        elif error.startswith(' ' * DEFAULT_SOURCE_OFFSET):
645            # TODO: detecting source code highlights through an indent can be surprising.
646            if '^' not in error:
647                return self.style(error, 'none', dim=True)
648            return self.style(error, 'red')
649        else:
650            return error
651
652    def highlight_quote_groups(self, msg: str) -> str:
653        """Make groups quoted with double quotes bold (including quotes).
654
655        This is used to highlight types, attribute names etc.
656        """
657        if msg.count('"') % 2:
658            # Broken error message, don't do any formatting.
659            return msg
660        parts = msg.split('"')
661        out = ''
662        for i, part in enumerate(parts):
663            if i % 2 == 0:
664                out += self.style(part, 'none')
665            else:
666                out += self.style('"' + part + '"', 'none', bold=True)
667        return out
668
669    def underline_link(self, note: str) -> str:
670        """Underline a link in a note message (if any).
671
672        This assumes there is at most one link in the message.
673        """
674        match = re.search(r'https?://\S*', note)
675        if not match:
676            return note
677        start = match.start()
678        end = match.end()
679        return (note[:start] +
680                self.style(note[start:end], 'none', underline=True) +
681                note[end:])
682
683    def format_success(self, n_sources: int, use_color: bool = True) -> str:
684        """Format short summary in case of success.
685
686        n_sources is total number of files passed directly on command line,
687        i.e. excluding stubs and followed imports.
688        """
689        msg = 'Success: no issues found in {}' \
690              ' source file{}'.format(n_sources, 's' if n_sources != 1 else '')
691        if not use_color:
692            return msg
693        return self.style(msg, 'green', bold=True)
694
695    def format_error(
696        self, n_errors: int, n_files: int, n_sources: int, *,
697        blockers: bool = False, use_color: bool = True
698    ) -> str:
699        """Format a short summary in case of errors."""
700
701        msg = 'Found {} error{} in {} file{}'.format(
702            n_errors, 's' if n_errors != 1 else '',
703            n_files, 's' if n_files != 1 else ''
704        )
705        if blockers:
706            msg += ' (errors prevented further checking)'
707        else:
708            msg += ' (checked {} source file{})'.format(n_sources, 's' if n_sources != 1 else '')
709        if not use_color:
710            return msg
711        return self.style(msg, 'red', bold=True)
712
713
714def is_typeshed_file(file: str) -> bool:
715    # gross, but no other clear way to tell
716    return 'typeshed' in os.path.abspath(file).split(os.sep)
717
718
719def is_stub_package_file(file: str) -> bool:
720    # Use hacky heuristics to check whether file is part of a PEP 561 stub package.
721    if not file.endswith('.pyi'):
722        return False
723    return any(component.endswith('-stubs')
724               for component in os.path.abspath(file).split(os.sep))
725