Repository: ambv/black (python)

Issue:
Indentation is incorrectly changed for tabulated comments after a dedent
Operating system: Ubuntu 18.04
Python version: 3.6.3
Black version: master

Thank you @ambv for this library. When trying to run this on a codebase that uses tabs, indentation for comments past depth 0 is changed incorrectly after a dedent. Sample input (NB tabs):

```
if 1:
	if 2:
		pass
	# This comment should be indented the same as the next line
	pass
```

Black reformats this to:

```
if 1:
    if 2:
        pass
        # This comment should be indented the same as the next line
    pass
```

Note that this only happens when the input file uses tabs.

Pull request:
Fix indent calculation with tabs when computing prefixes
Closes #262, bug seems to have been introduced in https://github.com/ambv/black/commit/54d707e10a5bf3d8d352c1bcbc7946bb6f3c01d7. Indent widths in lib2to3 for tabs are actually counted as 1, not 4, so consuming the prefix needs to match that.

Patch:
diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml
index 0d43112..c8a8ff0 100644
--- a/.pre-commit-hooks.yaml
+++ b/.pre-commit-hooks.yaml
@@ -4,4 +4,5 @@
     entry: black
     language: python
     language_version: python3
+    require_serial: true
     types: [python]
diff --git a/.travis.yml b/.travis.yml
index 83e07e4..cb28deb 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -2,14 +2,13 @@ sudo: required
 dist: xenial
 language: python
 cache: pip
+env:
+- TEST_CMD="coverage run tests/test_black.py"
 install:
 - pip install coverage coveralls flake8 flake8-bugbear mypy
 - pip install -e '.[d]'
 script:
-- coverage run tests/test_black.py
-- if [[ $TRAVIS_PYTHON_VERSION == '3.6' ]]; then mypy black.py blackd.py tests/test_black.py; fi
-- if [[ $TRAVIS_PYTHON_VERSION == '3.7' ]]; then black --check --verbose .; fi
-- if [[ $TRAVIS_PYTHON_VERSION == '3.8-dev' ]]; then flake8 black.py blackd.py tests/test_black.py; fi
+- $TEST_CMD
 after_success:
 - coveralls
 notifications:
@@ -17,9 +16,24 @@ notifications:
   on_failure: always
 matrix:
   include:
-    - python: 3.6
-    - python: 3.7
-    - python: 3.8-dev
+    - name: "mypy"
+      python: 3.6
+      env:
+        - TEST_CMD="mypy black.py blackd.py tests/test_black.py"
+    - name: "black"
+      python: 3.7
+      env:
+        - TEST_CMD="black --check --verbose ."
+    - name: "flake8"
+      python: 3.7
+      env:
+        - TEST_CMD="flake8 black.py blackd.py tests/test_black.py"
+    - name: "3.6"
+      python: 3.6
+    - name: "3.7"
+      python: 3.7
+    - name: "3.8-dev"
+      python: 3.8-dev
 before_deploy:
   - pip install pyinstaller
   - pyinstaller --clean -F --add-data blib2to3/:blib2to3 black.py
diff --git a/Pipfile b/Pipfile
index 8e9ad1e..ffb31aa 100644
--- a/Pipfile
+++ b/Pipfile
@@ -10,6 +10,7 @@ click = ">=6.5"
 appdirs = "*"
 toml = ">=0.9.4"
 black = {editable = true, path = ".", extras = ["d"]}
+aiohttp-cors = "*"
 
 [dev-packages]
 pre-commit = "*"
diff --git a/README.md b/README.md
index a255022..c548ce4 100644
--- a/README.md
+++ b/README.md
@@ -82,7 +82,7 @@ Options:
   -S, --skip-string-normalization
                               Don't normalize string quotes or prefixes.
   -N, --skip-numeric-underscore-normalization
-                              Don't normalize underscores in numeric literals.			      
+                              Don't normalize underscores in numeric literals.
   --check                     Don't write the files back, just return the
                               status.  Return code 0 means nothing would
                               change.  Return code 1 means some files would be
@@ -100,8 +100,8 @@ Options:
                               directories that should be excluded on
                               recursive searches. On Windows, use forward
                               slashes for directories.  [default:
-                              build/|buck-out/|dist/|_build/|\.git/|\.hg/|
-                              \.mypy_cache/|\.nox/|\.tox/|\.venv/]
+                              build/|buck-out/|dist/|_build/|\.eggs/|\.git/|
+                              \.hg/|\.mypy_cache/|\.nox/|\.tox/|\.venv/]
   -q, --quiet                 Don't emit non-error messages to stderr. Errors
                               are still emitted, silence those with
                               2>/dev/null.
@@ -238,13 +238,13 @@ the following configuration.
 multi_line_output=3
 include_trailing_comma=True
 force_grid_wrap=0
-combine_as_imports=True
+use_parentheses=True
 line_length=88
 ```
 
 The equivalent command line is:
 ```
-$ isort --multi-line=3 --trailing-comma --force-grid-wrap=0 --combine-as --line-width=88 [ file.py ]
+$ isort --multi-line=3 --trailing-comma --force-grid-wrap=0 --use-parentheses --line-width=88 [ file.py ]
 ```
 </details>
 
@@ -359,8 +359,8 @@ string literals that ended up on the same line (see
 [#26](https://github.com/ambv/black/issues/26) for details).
 
 Why settle on double quotes?  They anticipate apostrophes in English
-text.  They match the docstring standard described in PEP 257.  An
-empty string in double quotes (`""`) is impossible to confuse with
+text.  They match the docstring standard described in [PEP 257](https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring).
+An empty string in double quotes (`""`) is impossible to confuse with
 a one double-quote regardless of fonts and syntax highlighting used.
 On top of this, double quotes for strings are consistent with C which
 Python interacts a lot with.
@@ -548,7 +548,7 @@ other file.
 If you're running with `--verbose`, you will see a blue message if
 a file was found and used.
 
-Please note `blackd` will not use `pyproject.toml` configuration. 
+Please note `blackd` will not use `pyproject.toml` configuration.
 
 
 ### Configuration format
@@ -573,7 +573,8 @@ py36 = true
 include = '\.pyi?$'
 exclude = '''
 /(
-    \.git
+    \.eggs
+  | \.git
   | \.hg
   | \.mypy_cache
   | \.tox
@@ -726,7 +727,7 @@ Use the [Python extension](https://marketplace.visualstudio.com/items?itemName=m
 Use [sublack plugin](https://github.com/jgirardet/sublack).
 
 
-### IPython Notebook Magic
+### Jupyter Notebook Magic
 
 Use [blackcellmagic](https://github.com/csurfer/blackcellmagic).
 
@@ -792,14 +793,14 @@ Options:
 ### Protocol
 
 `blackd` only accepts `POST` requests at the `/` path. The body of the request
-should contain the python source code to be formatted, encoded 
+should contain the python source code to be formatted, encoded
 according to the `charset` field in the `Content-Type` request header. If no
 `charset` is specified, `blackd` assumes `UTF-8`.
 
 There are a few HTTP headers that control how the source is formatted. These
 correspond to command line flags for *Black*. There is one exception to this:
 `X-Protocol-Version` which if present, should have the value `1`, otherwise the
-request is rejected with `HTTP 501` (Not Implemented). 
+request is rejected with `HTTP 501` (Not Implemented).
 
 The headers controlling how code is formatted are:
 
@@ -1345,7 +1346,7 @@ Multiple contributions by:
 * [Luka Sterbic](mailto:luka.sterbic@gmail.com)
 * [Miguel Gaiowski](mailto:miggaiowski@gmail.com)
 * [Miroslav Shubernetskiy](mailto:miroslav@miki725.com)
-* [Neraste](neraste.herr10@gmail.com)
+* [Neraste](mailto:neraste.herr10@gmail.com)
 * [Osaetin Daniel](mailto:osaetindaniel@gmail.com)
 * [Peter Bengtsson](mailto:mail@peterbe.com)
 * [Stavros Korokithakis](mailto:hi@stavros.io)
diff --git a/black.py b/black.py
index 4d85865..c4b9132 100644
--- a/black.py
+++ b/black.py
@@ -5,15 +5,16 @@
 from enum import Enum, Flag
 from functools import lru_cache, partial, wraps
 import io
-import keyword
+import itertools
 import logging
-from multiprocessing import Manager
+from multiprocessing import Manager, freeze_support
 import os
 from pathlib import Path
 import pickle
 import re
 import signal
 import sys
+import tempfile
 import tokenize
 from typing import (
     Any,
@@ -50,7 +51,7 @@
 __version__ = "18.9b0"
 DEFAULT_LINE_LENGTH = 88
 DEFAULT_EXCLUDES = (
-    r"/(\.git|\.hg|\.mypy_cache|\.nox|\.tox|\.venv|_build|buck-out|build|dist)/"
+    r"/(\.eggs|\.git|\.hg|\.mypy_cache|\.nox|\.tox|\.venv|_build|buck-out|build|dist)/"
 )
 DEFAULT_INCLUDES = r"\.pyi?$"
 CACHE_DIR = Path(user_cache_dir("black", version=__version__))
@@ -159,7 +160,9 @@ def read_pyproject_toml(
         pyproject_toml = toml.load(value)
         config = pyproject_toml.get("tool", {}).get("black", {})
     except (toml.TomlDecodeError, OSError) as e:
-        raise click.BadOptionUsage(f"Error reading configuration file: {e}", ctx)
+        raise click.FileError(
+            filename=value, hint=f"Error reading configuration file: {e}"
+        )
 
     if not config:
         return None
@@ -774,9 +777,7 @@ def show(cls, code: Union[str, Leaf, Node]) -> None:
         list(v.visit(code))
 
 
-KEYWORDS = set(keyword.kwlist)
 WHITESPACE = {token.DEDENT, token.INDENT, token.NEWLINE}
-FLOW_CONTROL = {"return", "raise", "break", "continue"}
 STATEMENT = {
     syms.if_stmt,
     syms.while_stmt,
@@ -1025,7 +1026,9 @@ class Line:
 
     depth: int = 0
     leaves: List[Leaf] = Factory(list)
-    comments: List[Tuple[Index, Leaf]] = Factory(list)
+    # The LeafID keys of comments must remain ordered by the corresponding leaf's index
+    # in leaves
+    comments: Dict[LeafID, List[Leaf]] = Factory(dict)
     bracket_tracker: BracketTracker = Factory(BracketTracker)
     inside_brackets: bool = False
     should_explode: bool = False
@@ -1232,43 +1235,35 @@ def append_comment(self, comment: Leaf) -> bool:
         if comment.type != token.COMMENT:
             return False
 
-        after = len(self.leaves) - 1
-        if after == -1:
+        if not self.leaves:
             comment.type = STANDALONE_COMMENT
             comment.prefix = ""
             return False
 
         else:
-            self.comments.append((after, comment))
-            return True
-
-    def comments_after(self, leaf: Leaf, _index: int = -1) -> Iterator[Leaf]:
-        """Generate comments that should appear directly after `leaf`.
-
-        Provide a non-negative leaf `_index` to speed up the function.
-        """
-        if not self.comments:
-            return
-
-        if _index == -1:
-            for _index, _leaf in enumerate(self.leaves):
-                if leaf is _leaf:
-                    break
-
+            leaf_id = id(self.leaves[-1])
+            if leaf_id not in self.comments:
+                self.comments[leaf_id] = [comment]
             else:
-                return
+                self.comments[leaf_id].append(comment)
+            return True
 
-        for index, comment_after in self.comments:
-            if _index == index:
-                yield comment_after
+    def comments_after(self, leaf: Leaf) -> List[Leaf]:
+        """Generate comments that should appear directly after `leaf`."""
+        return self.comments.get(id(leaf), [])
 
     def remove_trailing_comma(self) -> None:
         """Remove the trailing comma and moves the comments attached to it."""
-        comma_index = len(self.leaves) - 1
-        for i in range(len(self.comments)):
-            comment_index, comment = self.comments[i]
-            if comment_index == comma_index:
-                self.comments[i] = (comma_index - 1, comment)
+        # Remember, the LeafID keys of self.comments are ordered by the
+        # corresponding leaf's index in self.leaves
+        # If id(self.leaves[-2]) is in self.comments, the order doesn't change.
+        # Otherwise, we insert it into self.comments, and it becomes the last entry.
+        # However, since we delete id(self.leaves[-1]) from self.comments, the invariant
+        # is maintained
+        self.comments.setdefault(id(self.leaves[-2]), []).extend(
+            self.comments.get(id(self.leaves[-1]), [])
+        )
+        self.comments.pop(id(self.leaves[-1]), None)
         self.leaves.pop()
 
     def is_complex_subscript(self, leaf: Leaf) -> bool:
@@ -1300,7 +1295,7 @@ def __str__(self) -> str:
         res = f"{first.prefix}{indent}{first.value}"
         for leaf in leaves:
             res += str(leaf)
-        for _, comment in self.comments:
+        for comment in itertools.chain.from_iterable(self.comments.values()):
             res += str(comment)
         return res + "\n"
 
@@ -1611,7 +1606,7 @@ def __attrs_post_init__(self) -> None:
 ALWAYS_NO_SPACE = CLOSING_BRACKETS | {token.COMMA, STANDALONE_COMMENT}
 
 
-def whitespace(leaf: Leaf, *, complex_subscript: bool) -> str:  # noqa C901
+def whitespace(leaf: Leaf, *, complex_subscript: bool) -> str:  # noqa: C901
     """Return whitespace prefix if needed for the given `leaf`.
 
     `complex_subscript` signals whether the given leaf is part of a subscription
@@ -1892,7 +1887,7 @@ def container_of(leaf: Leaf) -> LN:
     return container
 
 
-def is_split_after_delimiter(leaf: Leaf, previous: Leaf = None) -> int:
+def is_split_after_delimiter(leaf: Leaf, previous: Optional[Leaf] = None) -> int:
     """Return the priority of the `leaf` delimiter, given a line break after it.
 
     The delimiter priorities returned here are from those delimiters that would
@@ -1906,7 +1901,7 @@ def is_split_after_delimiter(leaf: Leaf, previous: Leaf = None) -> int:
     return 0
 
 
-def is_split_before_delimiter(leaf: Leaf, previous: Leaf = None) -> int:
+def is_split_before_delimiter(leaf: Leaf, previous: Optional[Leaf] = None) -> int:
     """Return the priority of the `leaf` delimiter, given a line break before it.
 
     The delimiter priorities returned here are from those delimiters that would
@@ -2115,8 +2110,19 @@ def split_line(
         return
 
     line_str = str(line).strip("\n")
-    if not line.should_explode and is_line_short_enough(
-        line, line_length=line_length, line_str=line_str
+
+    # we don't want to split special comments like type annotations
+    # https://github.com/python/typing/issues/186
+    has_special_comment = False
+    for leaf in line.leaves:
+        for comment in line.comments_after(leaf):
+            if leaf.type == token.COMMA and is_special_comment(comment):
+                has_special_comment = True
+
+    if (
+        not has_special_comment
+        and not line.should_explode
+        and is_line_short_enough(line, line_length=line_length, line_str=line_str)
     ):
         yield line
         return
@@ -2395,10 +2401,10 @@ def append_to_line(leaf: Leaf) -> Iterator[Line]:
             current_line = Line(depth=line.depth, inside_brackets=line.inside_brackets)
             current_line.append(leaf)
 
-    for index, leaf in enumerate(line.leaves):
+    for leaf in line.leaves:
         yield from append_to_line(leaf)
 
-        for comment_after in line.comments_after(leaf, index):
+        for comment_after in line.comments_after(leaf):
             yield from append_to_line(comment_after)
 
         lowest_depth = min(lowest_depth, leaf.bracket_depth)
@@ -2435,16 +2441,16 @@ def append_to_line(leaf: Leaf) -> Iterator[Line]:
         nonlocal current_line
         try:
             current_line.append_safe(leaf, preformatted=True)
-        except ValueError as ve:
+        except ValueError:
             yield current_line
 
             current_line = Line(depth=line.depth, inside_brackets=line.inside_brackets)
             current_line.append(leaf)
 
-    for index, leaf in enumerate(line.leaves):
+    for leaf in line.leaves:
         yield from append_to_line(leaf)
 
-        for comment_after in line.comments_after(leaf, index):
+        for comment_after in line.comments_after(leaf):
             yield from append_to_line(comment_after)
 
     if current_line:
@@ -2465,6 +2471,16 @@ def is_import(leaf: Leaf) -> bool:
     )
 
 
+def is_special_comment(leaf: Leaf) -> bool:
+    """Return True if the given leaf is a special comment.
+    Only returns true for type comments for now."""
+    t = leaf.type
+    v = leaf.value
+    return bool(
+        (t == token.COMMENT or t == STANDALONE_COMMENT) and (v.startswith("# type:"))
+    )
+
+
 def normalize_prefix(leaf: Leaf, *, inside_brackets: bool) -> None:
     """Leave existing extra newlines if not `inside_brackets`. Remove everything
     else.
@@ -2954,6 +2970,7 @@ def ensure_visible(leaf: Leaf) -> None:
 
 def should_explode(line: Line, opening_bracket: Leaf) -> bool:
     """Should `line` immediately be split with `delimiter_split()` after RHS?"""
+
     if not (
         opening_bracket.parent
         and opening_bracket.parent.type in {syms.atom, syms.import_from}
@@ -3436,7 +3453,7 @@ def enumerate_with_length(
             return  # Multiline strings, we can't continue.
 
         comment: Optional[Leaf]
-        for comment in line.comments_after(leaf, index):
+        for comment in line.comments_after(leaf):
             length += len(comment.value)
 
         yield index, leaf, length
@@ -3631,11 +3648,11 @@ def write_cache(
     """Update the cache file."""
     cache_file = get_cache_file(line_length, mode)
     try:
-        if not CACHE_DIR.exists():
-            CACHE_DIR.mkdir(parents=True)
+        CACHE_DIR.mkdir(parents=True, exist_ok=True)
         new_cache = {**cache, **{src.resolve(): get_cache_info(src) for src in sources}}
-        with cache_file.open("wb") as fobj:
-            pickle.dump(new_cache, fobj, protocol=pickle.HIGHEST_PROTOCOL)
+        with tempfile.NamedTemporaryFile(dir=str(cache_file.parent), delete=False) as f:
+            pickle.dump(new_cache, f, protocol=pickle.HIGHEST_PROTOCOL)
+        os.replace(f.name, cache_file)
     except OSError:
         pass
 
@@ -3663,6 +3680,7 @@ def patch_click() -> None:
 
 
 def patched_main() -> None:
+    freeze_support()
     patch_click()
     main()
 
diff --git a/blackd.py b/blackd.py
index e1006a1..1a3b369 100644
--- a/blackd.py
+++ b/blackd.py
@@ -2,8 +2,10 @@
 from concurrent.futures import Executor, ProcessPoolExecutor
 from functools import partial
 import logging
+from multiprocessing import freeze_support
 
 from aiohttp import web
+import aiohttp_cors
 import black
 import click
 
@@ -17,6 +19,15 @@
 SKIP_NUMERIC_UNDERSCORE_NORMALIZATION_HEADER = "X-Skip-Numeric-Underscore-Normalization"
 FAST_OR_SAFE_HEADER = "X-Fast-Or-Safe"
 
+BLACK_HEADERS = [
+    VERSION_HEADER,
+    LINE_LENGTH_HEADER,
+    PYTHON_VARIANT_HEADER,
+    SKIP_STRING_NORMALIZATION_HEADER,
+    SKIP_NUMERIC_UNDERSCORE_NORMALIZATION_HEADER,
+    FAST_OR_SAFE_HEADER,
+]
+
 
 @click.command(context_settings={"help_option_names": ["-h", "--help"]})
 @click.option(
@@ -35,7 +46,18 @@ def main(bind_host: str, bind_port: int) -> None:
 def make_app() -> web.Application:
     app = web.Application()
     executor = ProcessPoolExecutor()
-    app.add_routes([web.post("/", partial(handle, executor=executor))])
+
+    cors = aiohttp_cors.setup(app)
+    resource = cors.add(app.router.add_resource("/"))
+    cors.add(
+        resource.add_route("POST", partial(handle, executor=executor)),
+        {
+            "*": aiohttp_cors.ResourceOptions(
+                allow_headers=(*BLACK_HEADERS, "Content-Type"), expose_headers="*"
+            )
+        },
+    )
+
     return app
 
 
@@ -109,6 +131,7 @@ async def handle(request: web.Request, executor: Executor) -> web.Response:
 
 
 def patched_main() -> None:
+    freeze_support()
     black.patch_click()
     main()
 
diff --git a/blib2to3/pgen2/driver.py b/blib2to3/pgen2/driver.py
index 72d9f47..6626c05 100644
--- a/blib2to3/pgen2/driver.py
+++ b/blib2to3/pgen2/driver.py
@@ -131,10 +131,8 @@ def _partially_consume_prefix(self, prefix, column):
                     current_line = ""
                     current_column = 0
                     wait_for_nl = False
-            elif char == ' ':
+            elif char in ' \t':
                 current_column += 1
-            elif char == '\t':
-                current_column += 4
             elif char == '\n':
                 # unexpected empty line
                 current_column = 0
diff --git a/blib2to3/pgen2/grammar.py b/blib2to3/pgen2/grammar.py
index a1da546..b20b923 100644
--- a/blib2to3/pgen2/grammar.py
+++ b/blib2to3/pgen2/grammar.py
@@ -13,7 +13,9 @@
 """
 
 # Python imports
+import os.path
 import pickle
+import tempfile
 
 # Local imports
 from . import token
@@ -86,8 +88,9 @@ def __init__(self):
 
     def dump(self, filename):
         """Dump the grammar tables to a pickle file."""
-        with open(filename, "wb") as f:
+        with tempfile.NamedTemporaryFile(dir=os.path.dirname(filename), delete=False) as f:
             pickle.dump(self.__dict__, f, pickle.HIGHEST_PROTOCOL)
+        os.replace(f.name, filename)
 
     def load(self, filename):
         """Load the grammar tables from a pickle file."""
diff --git a/pyproject.toml b/pyproject.toml
index 6ac19bc..fca5055 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,7 +11,8 @@ py36 = true
 include = '\.pyi?$'
 exclude = '''
 /(
-    \.git
+    \.eggs
+  | \.git
   | \.hg
   | \.mypy_cache
   | \.tox


Test patch:
diff --git a/tests/data/comments6.py b/tests/data/comments6.py
new file mode 100644
index 0000000..770e3cc
--- /dev/null
+++ b/tests/data/comments6.py
@@ -0,0 +1,68 @@
+from typing import Any, Tuple
+
+
+def f(
+    a,  # type: int
+):
+    pass
+
+
+# test type comments
+def f(a, b, c, d, e, f, g, h, i):
+    # type: (int, int, int, int, int, int, int, int, int) -> None
+    pass
+
+
+def f(
+    a,  # type: int
+    b,  # type: int
+    c,  # type: int
+    d,  # type: int
+    e,  # type: int
+    f,  # type: int
+    g,  # type: int
+    h,  # type: int
+    i,  # type: int
+):
+    # type: (...) -> None
+    pass
+
+
+def f(
+    arg,  # type: int
+    *args,  # type: *Any
+    default=False,  # type: bool
+    **kwargs,  # type: **Any
+):
+    # type: (...) -> None
+    pass
+
+
+def f(
+    a,  # type: int
+    b,  # type: int
+    c,  # type: int
+    d,  # type: int
+):
+    # type: (...) -> None
+
+    element = 0  # type: int
+    another_element = 1  # type: float
+    another_element_with_long_name = 2  # type: int
+    another_really_really_long_element_with_a_unnecessarily_long_name_to_describe_what_it_does_enterprise_style = (
+        3
+    )  # type: int
+
+    tup = (
+        another_element,  # type: int
+        another_really_really_long_element_with_a_unnecessarily_long_name_to_describe_what_it_does_enterprise_style,  # type: int
+    )  # type: Tuple[int, int]
+
+    a = (
+        element
+        + another_element
+        + another_element_with_long_name
+        + element
+        + another_element
+        + another_element_with_long_name
+    )  # type: int
diff --git a/tests/test_black.py b/tests/test_black.py
index 6ff5840..92031ca 100644
--- a/tests/test_black.py
+++ b/tests/test_black.py
@@ -1,7 +1,7 @@
 #!/usr/bin/env python3
 import asyncio
 from concurrent.futures import ThreadPoolExecutor
-from contextlib import contextmanager
+from contextlib import contextmanager, redirect_stderr
 from functools import partial, wraps
 from io import BytesIO, TextIOWrapper
 import os
@@ -289,7 +289,7 @@ def test_expression_diff(self) -> None:
             msg = (
                 f"Expected diff isn't equal to the actual. If you made changes "
                 f"to expression.py and this is an anticipated difference, "
-                f"overwrite tests/expression.diff with {dump}"
+                f"overwrite tests/data/expression.diff with {dump}"
             )
             self.assertEqual(expected, actual, msg)
 
@@ -362,6 +362,14 @@ def test_comments5(self) -> None:
         black.assert_equivalent(source, actual)
         black.assert_stable(source, actual, line_length=ll)
 
+    @patch("black.dump_to_file", dump_to_stderr)
+    def test_comments6(self) -> None:
+        source, expected = read_data("comments6")
+        actual = fs(source)
+        self.assertFormatEqual(expected, actual)
+        black.assert_equivalent(source, actual)
+        black.assert_stable(source, actual, line_length=ll)
+
     @patch("black.dump_to_file", dump_to_stderr)
     def test_cantfit(self) -> None:
         source, expected = read_data("cantfit")
@@ -501,6 +509,19 @@ def test_bracket_match(self) -> None:
         black.assert_equivalent(source, actual)
         black.assert_stable(source, actual, line_length=ll)
 
+    def test_comment_indentation(self) -> None:
+        contents_tab = "if 1:\n\tif 2:\n\t\tpass\n\t# comment\n\tpass\n"
+        contents_spc = "if 1:\n    if 2:\n        pass\n    # comment\n    pass\n"
+
+        self.assertFormatEqual(fs(contents_spc), contents_spc)
+        self.assertFormatEqual(fs(contents_tab), contents_spc)
+
+        contents_tab = "if 1:\n\tif 2:\n\t\tpass\n\t\t# comment\n\tpass\n"
+        contents_spc = "if 1:\n    if 2:\n        pass\n        # comment\n    pass\n"
+
+        self.assertFormatEqual(fs(contents_tab), contents_spc)
+        self.assertFormatEqual(fs(contents_spc), contents_spc)
+
     def test_report_verbose(self) -> None:
         report = black.Report(verbose=True)
         out_lines = []
@@ -1291,7 +1312,7 @@ def test_symlink_out_of_root_directory(self) -> None:
         try:
             list(black.gen_python_files_in_dir(path, root, include, exclude, report))
         except ValueError as ve:
-            self.fail("`get_python_files_in_dir()` failed: {ve}")
+            self.fail(f"`get_python_files_in_dir()` failed: {ve}")
         path.iterdir.assert_called_once()
         child.resolve.assert_called_once()
         child.is_symlink.assert_called_once()
@@ -1454,15 +1475,16 @@ async def test_blackd_py36(self) -> None:
     @unittest.skipUnless(has_blackd_deps, "blackd's dependencies are not installed")
     @async_test
     async def test_blackd_fast(self) -> None:
-        app = blackd.make_app()
-        async with TestClient(TestServer(app)) as client:
-            response = await client.post("/", data=b"ur'hello'")
-            self.assertEqual(response.status, 500)
-            self.assertIn("failed to parse source file", await response.text())
-            response = await client.post(
-                "/", data=b"ur'hello'", headers={blackd.FAST_OR_SAFE_HEADER: "fast"}
-            )
-            self.assertEqual(response.status, 200)
+        with open(os.devnull, "w") as dn, redirect_stderr(dn):
+            app = blackd.make_app()
+            async with TestClient(TestServer(app)) as client:
+                response = await client.post("/", data=b"ur'hello'")
+                self.assertEqual(response.status, 500)
+                self.assertIn("failed to parse source file", await response.text())
+                response = await client.post(
+                    "/", data=b"ur'hello'", headers={blackd.FAST_OR_SAFE_HEADER: "fast"}
+                )
+                self.assertEqual(response.status, 200)
 
     @unittest.skipUnless(has_blackd_deps, "blackd's dependencies are not installed")
     @async_test