Repository: psf/black
Pull request: Provide a stdin-filename to allow stdin to respect force-exclude rules
Fix #1776 

Problem statement:
Provide a --stdin-display-name option like flake8
I'm trying to use `black` as a fixer on ale on vim. It works fine and my config file is respected, except for the `exclude` and `force-exclude` files/folders.

The problem is that the tool calls black passing the code through stdin, and thus, black doesn't know the filename that is being parsed.

flake8 workas around that issue by providing a `--stdin-display-name` which can be used to identify which is the name of the file being passed through stdin, allowing those exclude rules to be followed.

What do you think?

Hints from issue discussion:

Commit message: Provide a stdin-filename to allow stdin to respect exclude/force-exclude rules

This will allow automatic tools to enforce the project's
exclude/force-exclude rules even if they pass the file through stdin to
update its buffer.

This is a similar solution to --stdin-display-name in flake8.

Patch to review:
diff --git a/src/black/__init__.py b/src/black/__init__.py
index 24e9d4edaaa..7fab0656d19 100644
--- a/src/black/__init__.py
+++ b/src/black/__init__.py
@@ -457,6 +457,15 @@ def target_version_option_callback(
         "excluded even when they are passed explicitly as arguments."
     ),
 )
+@click.option(
+    "--stdin-filename",
+    type=str,
+    help=(
+        "The name of the file when passing it through stdin. Useful to make "
+        "sure black will respect --exclude/--force-exclude options on some "
+        "editors that rely on using stdin."
+    ),
+)
 @click.option(
     "-q",
     "--quiet",
@@ -516,6 +525,7 @@ def main(
     include: str,
     exclude: str,
     force_exclude: Optional[str],
+    stdin_filename: Optional[str],
     src: Tuple[str, ...],
     config: Optional[str],
 ) -> None:
@@ -548,6 +558,7 @@ def main(
         exclude=exclude,
         force_exclude=force_exclude,
         report=report,
+        stdin_filename=stdin_filename,
     )
 
     path_empty(
@@ -587,6 +598,7 @@ def get_sources(
     exclude: str,
     force_exclude: Optional[str],
     report: "Report",
+    stdin_filename: Optional[str],
 ) -> Set[Path]:
     """Compute the set of files to be formatted."""
     try:
@@ -613,7 +625,13 @@ def get_sources(
     gitignore = get_gitignore(root)
 
     for s in src:
-        p = Path(s)
+        if s == "-" and stdin_filename:
+            p = Path(stdin_filename)
+            is_stdin = True
+        else:
+            p = Path(s)
+            is_stdin = False
+
         if p.is_dir():
             sources.update(
                 gen_python_files(
@@ -626,8 +644,6 @@ def get_sources(
                     gitignore,
                 )
             )
-        elif s == "-":
-            sources.add(p)
         elif p.is_file():
             normalized_path = normalize_path_maybe_ignore(p, root, report)
             if normalized_path is None:
@@ -643,6 +659,11 @@ def get_sources(
                 report.path_ignored(p, "matches the --force-exclude regular expression")
                 continue
 
+            if is_stdin:
+                p = Path("__BLACK_STDIN_FILENAME__" / p)
+
+            sources.add(p)
+        elif s == "-":
             sources.add(p)
         else:
             err(f"invalid path: {s}")
@@ -670,7 +691,9 @@ def reformat_one(
     """
     try:
         changed = Changed.NO
-        if not src.is_file() and str(src) == "-":
+        if not src.is_file() and (
+            str(src) == "-" or str(src).startswith("__BLACK_STDIN_FILENAME__")
+        ):
             if format_stdin_to_stdout(fast=fast, write_back=write_back, mode=mode):
                 changed = Changed.YES
         else:
diff --git a/tests/test_black.py b/tests/test_black.py
index 7927b368b5d..ed108671836 100644
--- a/tests/test_black.py
+++ b/tests/test_black.py
@@ -1730,6 +1730,7 @@ def test_exclude_for_issue_1572(self) -> None:
                 exclude=exclude,
                 force_exclude=None,
                 report=report,
+                stdin_filename=None,
             )
         )
         self.assertEqual(sorted(expected), sorted(sources))


Code review comments:
diff_hunk: @@ -626,8 +644,6 @@ def get_sources(
                     gitignore,
                 )
             )
-        elif s == "-":
-            sources.add(p)
         elif p.is_file():
path: src/black/__init__.py
text: @user2:
BTW `--exclude` isn't checked here as it is only checked against recursively found files and directories so the help for your new option is a bit misleading. Only `--force-exclude` is checked.

```console
(black) ichard26@user1-ubuntu:~/programming/oss/black$ echo "print('hello world')" | black -v --force-exclude="hello.*" --stdin-filename="hello.py" -
hello.py ignored: matches the --force-exclude regular expression
No Python files are present to be formatted. Nothing to do 😴
(black) ichard26@user1-ubuntu:~/programming/oss/black$ echo "print('hello world')" | black -v --exclude="hello.*" --stdin-filename="hello.py" -
print("hello world")
reformatted __BLACK_STDIN_FILENAME__/hello.py
All done! ✨ 🍰 ✨
1 file reformatted.
```
**NOTE**: the suggestion above must be applied for this demo to work

@author:
Yeah, you are right! Since this is a solution that passing the path itself, it should not enforce exclude but only force-exclude.

Will make the changes together with the change on README and tests.

diff_hunk: @@ -626,8 +644,6 @@ def get_sources(
                     gitignore,
                 )
             )
-        elif s == "-":
-            sources.add(p)
         elif p.is_file():
path: src/black/__init__.py
text: @user2:
```suggestion
        elif p.is_file() or is_stdin:
```

With a custom stdin filepath given, Black will try to format it in place as if it was a file:

```console
(black) ichard26@user1-ubuntu:~/programming/oss/black$ echo "print('hello world')" | black -v --stdin-filename="hello.py" -
Traceback (most recent call last):
  File "/home/ichard26/programming/oss/black/src/black/__init__.py", line 706, in reformat_one
    if changed is not Changed.CACHED and format_file_in_place(
  File "/home/ichard26/programming/oss/black/src/black/__init__.py", line 845, in format_file_in_place
    then = datetime.utcfromtimestamp(src.stat().st_mtime)
  File "/opt/python3.8.5/lib/python3.8/pathlib.py", line 1194, in stat
    return self._accessor.stat(self)
FileNotFoundError: [Errno 2] No such file or directory: 'hello.py'
error: cannot format hello.py: [Errno 2] No such file or directory: 'hello.py'
Oh no! 💥 💔 💥
1 file failed to reformat.
```

This is because `Path("-").is_file` returns `False` because a file doesn't actually exist at that path ([See `pathlib.Path.is_file` docs](https://docs.python.org/3.8/library/pathlib.html#pathlib.Path.is_file)). Therefore the path isn't prefixed with the magic string that tells `reformat_one` to use `format_stdin_to_stdout` and not `format_file_in_place`. Also `force-exclude` wouldn't be checked, but Black trying to format a file that doesn't exist is a bigger problem without this suggestion :-)