Repository: psf/black

Issue:
`--verbose` mode should print location of project root
**Describe the bug** A clear and concise description of what the bug is.

Black 20.8b1 does not respect skip-string-normalization = true in the pyproject.toml

**To Reproduce** Steps to reproduce the behavior:

1. pyproject.toml
```
[tool.black]
target-version = ['py36']
line-length = 120
skip-string-normalization = true
exclude = '(.*/migrations/.*\.py)'
include = '.*/server/.*\.py$'
```

2. create a python file with single strings

```
example = 'This is an example'
```

3. run `black <filename> --check --diff` from same directory as pyproject.toml (which is root repo dir)

**Expected behavior** A clear and concise description of what you expected to happen.

No changes would be applied by black given our pyproject.toml. Note that if you pass `--config` to that same command it works. 

**Environment (please complete the following information):**

- Version: 20.8b1
- OS and Python version: macOS 10.15.7, Python 3.7.2 (but target version is 3.6)

**Does this bug also happen on master?** To answer this, you have two options:

Yes with:

> 2. Or run _Black_ on your machine:
>    - create a new virtualenv (make sure it's the same Python version);
>    - clone this repository;
>    - run `pip install -e .`;
>    - make sure it's sane by running `python -m unittest`; and
>    - run `black` like you did last time.
> 

**Additional context** Add any other context about the problem here.

We haven't normalized our strings yet because it's a ~30k line change (we plan to in the coming months). Noting that it seems to be a general "not respecting pyproject.toml" issue but string normalization was the most obvious thing.


Discussion:
Thanks for reporting, but hmm I cannot reproduce:

```console
ichard26@acer-ubuntu:~/programming/testing$ mkdir black1880
ichard26@acer-ubuntu:~/programming/testing$ cd black1880/
ichard26@acer-ubuntu:~/programming/testing/black1880$ python -m virtualenv env/
ichard26@acer-ubuntu:~/programming/testing/black1880$ actenv
(env) ichard26@acer-ubuntu:~/programming/testing/black1880$ pip install black==20.8b1
(env) ichard26@acer-ubuntu:~/programming/testing/black1880$ vim pyproject.toml
(env) ichard26@acer-ubuntu:~/programming/testing/black1880$ vim test.py
(env) ichard26@acer-ubuntu:~/programming/testing/black1880$ black test.py --check --diff -v
Using configuration from /home/ichard26/programming/testing/black1880/pyproject.toml.
test.py already well formatted, good job.
All done! ✨ 🍰 ✨
1 file would be left unchanged.
(env) ichard26@acer-ubuntu:~/programming/testing/black1880$ cat pyproject.toml 
[tool.black]
target-version = ['py36']
line-length = 120
skip-string-normalization = true
exclude = '(.*/migrations/.*\.py)'
include = '.*/server/.*\.py$'
(env) ichard26@acer-ubuntu:~/programming/testing/black1880$ cat test.py
example = 'This is an example'
```
Hmm. I realize there's an implementation detail that mattered: the subdirectory we had the file in (server/) had a .git directory due to a docker quirk. Looking at find_pyproject_toml() and testing by removing the .git directory, it looks like black looks at the lowest directory with a .git and then uses that as the "root"?

new way to repro:

same files you had before, then do (in your test dir)

```mkdir subdir; mkdir subdir/.git/; mv test.py subdir/; black subdir/test.py --check --diff```

This is documented here: https://black.readthedocs.io/en/stable/pyproject_toml.html, so technically not an issue. 
Yeah, it's intended behaviour to first look in the common parent directory of the discovered or/and passed-in files for a "project root" indicator. Although, I'll admit it isn't exactly clear at first glance, even with `--verbose`. Perhaps it would be helpful to have Black print what folder it determined as the project root when verbose output is enabled? (I know that the root of the filesystem fallback doesn't really count as the root of any project but we can bikeshed over that later)
+1 more debug info. We can close this as "user shouldn't code after 6pm"
Got a file:
```
$ cat blacktest.py
example = 'This is an example'


def func():
    '''this is a docstring'''
    pass


def func2():
    '''
    this is a docstring
    '''
    pass
```

When run with black==19.10b and `skip-string-normalization = true` in pyproject.toml, nothing happens.

When I do the same with latest black (20.8b1) I get:
```
$ black blacktest.py --check --diff
would reformat blacktest.py
Oh no! 💥 💔 💥
1 file would be reformatted.
--- blacktest.py	2021-02-20 01:44:06.141640 +0000
+++ blacktest.py	2021-02-20 01:47:05.430516 +0000
@@ -5,9 +5,9 @@
     '''this is a docstring'''
     pass


 def func2():
-    '''
+    """
     this is a docstring
-    '''
+    """
     pass
```

I.e. it does seem like black 20.8b1 _is_ indeed converting single-quote docstrings into double-quote docstrings, at least when the docstring starts with a newline.

Should that still be considered a bug, or what do you think @ichard26 ?
@tomage yeah that would be considered a bug. Fortunately that's been reported and fixed already (see [commit](https://github.com/psf/black/commit/7fe19fac5bccc08dbcf21c293ab50f1fcda7716f), [PR](https://github.com/psf/black/pull/1637), and [issue](https://github.com/psf/black/issues/1634)). Unfortunately the fix hasn't been shipped in a release yet.
Ah - dang - should have noticed that one! Thanks for the update!
Going to rename this issue to reflect @ichard26's suggestion that `--verbose` should print more information.
Had my eyes on implementing this. The core issue seems to be getting `verbose` and `find_project_root` to communicate, because parsing the configuration is done in the option declaration rather than in the main CLI function. Just to steer the implementation: would you prefer moving parsing the configuration inside main from the option declaration, *or* set some global state that is then read in main for verbose printing?

Resolving patch:
diff --git a/src/black/__init__.py b/src/black/__init__.py
--- a/src/black/__init__.py
+++ b/src/black/__init__.py
@@ -31,6 +31,7 @@
 )
 
 import click
+from click.core import ParameterSource
 from dataclasses import replace
 from mypy_extensions import mypyc_attr
 
@@ -411,8 +412,37 @@ def main(
     config: Optional[str],
 ) -> None:
     """The uncompromising code formatter."""
-    if config and verbose:
-        out(f"Using configuration from {config}.", bold=False, fg="blue")
+    ctx.ensure_object(dict)
+    root, method = find_project_root(src) if code is None else (None, None)
+    ctx.obj["root"] = root
+
+    if verbose:
+        if root:
+            out(
+                f"Identified `{root}` as project root containing a {method}.",
+                fg="blue",
+            )
+
+            normalized = [
+                (normalize_path_maybe_ignore(Path(source), root), source)
+                for source in src
+            ]
+            srcs_string = ", ".join(
+                [
+                    f'"{_norm}"'
+                    if _norm
+                    else f'\033[31m"{source} (skipping - invalid)"\033[34m'
+                    for _norm, source in normalized
+                ]
+            )
+            out(f"Sources to be formatted: {srcs_string}", fg="blue")
+
+        if config:
+            config_source = ctx.get_parameter_source("config")
+            if config_source in (ParameterSource.DEFAULT, ParameterSource.DEFAULT_MAP):
+                out("Using configuration from project root.", fg="blue")
+            else:
+                out(f"Using configuration in '{config}'.", fg="blue")
 
     error_msg = "Oh no! 💥 💔 💥"
     if required_version and required_version != __version__:
@@ -516,14 +546,12 @@ def get_sources(
     stdin_filename: Optional[str],
 ) -> Set[Path]:
     """Compute the set of files to be formatted."""
-
-    root = find_project_root(src)
     sources: Set[Path] = set()
     path_empty(src, "No Path provided. Nothing to do 😴", quiet, verbose, ctx)
 
     if exclude is None:
         exclude = re_compile_maybe_verbose(DEFAULT_EXCLUDES)
-        gitignore = get_gitignore(root)
+        gitignore = get_gitignore(ctx.obj["root"])
     else:
         gitignore = None
 
@@ -536,7 +564,7 @@ def get_sources(
             is_stdin = False
 
         if is_stdin or p.is_file():
-            normalized_path = normalize_path_maybe_ignore(p, root, report)
+            normalized_path = normalize_path_maybe_ignore(p, ctx.obj["root"], report)
             if normalized_path is None:
                 continue
 
@@ -563,7 +591,7 @@ def get_sources(
             sources.update(
                 gen_python_files(
                     p.iterdir(),
-                    root,
+                    ctx.obj["root"],
                     include,
                     exclude,
                     extend_exclude,
diff --git a/src/black/files.py b/src/black/files.py
--- a/src/black/files.py
+++ b/src/black/files.py
@@ -31,7 +31,7 @@
 
 
 @lru_cache()
-def find_project_root(srcs: Sequence[str]) -> Path:
+def find_project_root(srcs: Sequence[str]) -> Tuple[Path, str]:
     """Return a directory containing .git, .hg, or pyproject.toml.
 
     That directory will be a common parent of all files and directories
@@ -39,6 +39,10 @@ def find_project_root(srcs: Sequence[str]) -> Path:
 
     If no directory in the tree contains a marker that would specify it's the
     project root, the root of the file system is returned.
+
+    Returns a two-tuple with the first element as the project root path and
+    the second element as a string describing the method by which the
+    project root was discovered.
     """
     if not srcs:
         srcs = [str(Path.cwd().resolve())]
@@ -58,20 +62,20 @@ def find_project_root(srcs: Sequence[str]) -> Path:
 
     for directory in (common_base, *common_base.parents):
         if (directory / ".git").exists():
-            return directory
+            return directory, ".git directory"
 
         if (directory / ".hg").is_dir():
-            return directory
+            return directory, ".hg directory"
 
         if (directory / "pyproject.toml").is_file():
-            return directory
+            return directory, "pyproject.toml"
 
-    return directory
+    return directory, "file system root"
 
 
 def find_pyproject_toml(path_search_start: Tuple[str, ...]) -> Optional[str]:
     """Find the absolute filepath to a pyproject.toml if it exists"""
-    path_project_root = find_project_root(path_search_start)
+    path_project_root, _ = find_project_root(path_search_start)
     path_pyproject_toml = path_project_root / "pyproject.toml"
     if path_pyproject_toml.is_file():
         return str(path_pyproject_toml)
@@ -133,7 +137,9 @@ def get_gitignore(root: Path) -> PathSpec:
 
 
 def normalize_path_maybe_ignore(
-    path: Path, root: Path, report: Report
+    path: Path,
+    root: Path,
+    report: Optional[Report] = None,
 ) -> Optional[str]:
     """Normalize `path`. May return `None` if `path` was ignored.
 
@@ -143,12 +149,16 @@ def normalize_path_maybe_ignore(
         abspath = path if path.is_absolute() else Path.cwd() / path
         normalized_path = abspath.resolve().relative_to(root).as_posix()
     except OSError as e:
-        report.path_ignored(path, f"cannot be read because {e}")
+        if report:
+            report.path_ignored(path, f"cannot be read because {e}")
         return None
 
     except ValueError:
         if path.is_symlink():
-            report.path_ignored(path, f"is a symbolic link that points outside {root}")
+            if report:
+                report.path_ignored(
+                    path, f"is a symbolic link that points outside {root}"
+                )
             return None
 
         raise


Test patch:
diff --git a/tests/test_black.py b/tests/test_black.py
--- a/tests/test_black.py
+++ b/tests/test_black.py
@@ -100,6 +100,8 @@ class FakeContext(click.Context):
 
     def __init__(self) -> None:
         self.default_map: Dict[str, Any] = {}
+        # Dummy root, since most of the tests don't care about it
+        self.obj: Dict[str, Any] = {"root": PROJECT_ROOT}
 
 
 class FakeParameter(click.Parameter):
@@ -1350,10 +1352,17 @@ def test_find_project_root(self) -> None:
             src_python.touch()
 
             self.assertEqual(
-                black.find_project_root((src_dir, test_dir)), root.resolve()
+                black.find_project_root((src_dir, test_dir)),
+                (root.resolve(), "pyproject.toml"),
+            )
+            self.assertEqual(
+                black.find_project_root((src_dir,)),
+                (src_dir.resolve(), "pyproject.toml"),
+            )
+            self.assertEqual(
+                black.find_project_root((src_python,)),
+                (src_dir.resolve(), "pyproject.toml"),
             )
-            self.assertEqual(black.find_project_root((src_dir,)), src_dir.resolve())
-            self.assertEqual(black.find_project_root((src_python,)), src_dir.resolve())
 
     @patch(
         "black.files.find_user_pyproject_toml",
@@ -1756,6 +1765,7 @@ def assert_collected_sources(
     src: Sequence[Union[str, Path]],
     expected: Sequence[Union[str, Path]],
     *,
+    ctx: Optional[FakeContext] = None,
     exclude: Optional[str] = None,
     include: Optional[str] = None,
     extend_exclude: Optional[str] = None,
@@ -1771,7 +1781,7 @@ def assert_collected_sources(
     )
     gs_force_exclude = None if force_exclude is None else compile_pattern(force_exclude)
     collected = black.get_sources(
-        ctx=FakeContext(),
+        ctx=ctx or FakeContext(),
         src=gs_src,
         quiet=False,
         verbose=False,
@@ -1807,9 +1817,11 @@ def test_gitignore_used_as_default(self) -> None:
             base / "b/.definitely_exclude/a.pyi",
         ]
         src = [base / "b/"]
-        assert_collected_sources(src, expected, extend_exclude=r"/exclude/")
+        ctx = FakeContext()
+        ctx.obj["root"] = base
+        assert_collected_sources(src, expected, ctx=ctx, extend_exclude=r"/exclude/")
 
-    @patch("black.find_project_root", lambda *args: THIS_DIR.resolve())
+    @patch("black.find_project_root", lambda *args: (THIS_DIR.resolve(), None))
     def test_exclude_for_issue_1572(self) -> None:
         # Exclude shouldn't touch files that were explicitly given to Black through the
         # CLI. Exclude is supposed to only apply to the recursive discovery of files.
@@ -1992,13 +2004,13 @@ def test_symlink_out_of_root_directory(self) -> None:
         child.is_symlink.assert_called()
         assert child.is_symlink.call_count == 2
 
-    @patch("black.find_project_root", lambda *args: THIS_DIR.resolve())
+    @patch("black.find_project_root", lambda *args: (THIS_DIR.resolve(), None))
     def test_get_sources_with_stdin(self) -> None:
         src = ["-"]
         expected = ["-"]
         assert_collected_sources(src, expected, include="", exclude=r"/exclude/|a\.py")
 
-    @patch("black.find_project_root", lambda *args: THIS_DIR.resolve())
+    @patch("black.find_project_root", lambda *args: (THIS_DIR.resolve(), None))
     def test_get_sources_with_stdin_filename(self) -> None:
         src = ["-"]
         stdin_filename = str(THIS_DIR / "data/collections.py")
@@ -2010,7 +2022,7 @@ def test_get_sources_with_stdin_filename(self) -> None:
             stdin_filename=stdin_filename,
         )
 
-    @patch("black.find_project_root", lambda *args: THIS_DIR.resolve())
+    @patch("black.find_project_root", lambda *args: (THIS_DIR.resolve(), None))
     def test_get_sources_with_stdin_filename_and_exclude(self) -> None:
         # Exclude shouldn't exclude stdin_filename since it is mimicking the
         # file being passed directly. This is the same as
@@ -2026,7 +2038,7 @@ def test_get_sources_with_stdin_filename_and_exclude(self) -> None:
             stdin_filename=stdin_filename,
         )
 
-    @patch("black.find_project_root", lambda *args: THIS_DIR.resolve())
+    @patch("black.find_project_root", lambda *args: (THIS_DIR.resolve(), None))
     def test_get_sources_with_stdin_filename_and_extend_exclude(self) -> None:
         # Extend exclude shouldn't exclude stdin_filename since it is mimicking the
         # file being passed directly. This is the same as
@@ -2042,7 +2054,7 @@ def test_get_sources_with_stdin_filename_and_extend_exclude(self) -> None:
             stdin_filename=stdin_filename,
         )
 
-    @patch("black.find_project_root", lambda *args: THIS_DIR.resolve())
+    @patch("black.find_project_root", lambda *args: (THIS_DIR.resolve(), None))
     def test_get_sources_with_stdin_filename_and_force_exclude(self) -> None:
         # Force exclude should exclude the file when passing it through
         # stdin_filename