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:
ichard26: OK yeah, not knowing what project root makes my suggestion a confusing in its own way :disappointed: .

> [...] the sources to check are being taken from here

Technically true but I don't think it makes it much clearer especially since it can be confusing when the SRCs are partially distanced from the root. Let's take this example that would happen if no root was found where we fallback to the filesystem root.

```console
~/programming/oss/black/docs on branch feat/project-root-verbose [$?] via Python v3.8.5 (black) 
$ black _build/html/ --check  --verbose
Checking for `_build/html/` in /
```

This isn't clear it's talking about the project root, and it's unclear what "checking for `_build/html` in ..." means here. The user could easily misunderstand this as checking the whole directory for directory structures that match `_build/html` which isn't right. To be fair this is also a mark against my suggestion since `Identified / as project root` is also invalid (usually as chances are we couldn't identify one).
Shivansh-007: > To be fair, I kinda like the colours. Hurts the focus of this PR but might not be that big of a deal smile But we need to discuss more (or just get a go-ahead from other maintainers) about the content of the messages. And btw, it seems odd to first declare that config from root is used and then that the root was found.

Should I use the path of the configuration then?
felix-hilden: As discussed on Discord, Jason thought we could use the click context to pass root around more easily, and include it in the report object so that it doesn't have to be in every method call. I think they could be nice simplifications. Thoughts?

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index bfee1b6..f6e8343 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -22,6 +22,8 @@
   `values: Tuple[int, ...] = 1, 2, 3`) now implies 3.8+ (#2708)
 - For stubs, one blank line between class attributes and methods is now kept if there's
   at least one pre-existing blank line (#2736)
+- Verbose mode also now describes how a project root was discovered and which paths will
+  be formatted. (#2526)
 
 ### Packaging
 
diff --git a/src/black/__init__.py b/src/black/__init__.py
index 283c53f..cfa2c76 100644
--- 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
index dfab9f7..18c8423 100644
--- 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
index 5be4ae8..91d1058 100644
--- 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