Repository: psf/black

Issue:
cache uses filenames that might be too long on some systems
**Describe the bug**

Running black 24.1.0 (without --check) can produce filenames in user's cache folder that are too long for some filesystems e.g. eCryptFS only allows filenames that are 143 chars long

The resulting error is:

```
Traceback (most recent call last):
  File "/home/hottwaj/.pyenv/versions/3.10.2/envs/test-venv/bin/black", line 8, in <module>
    sys.exit(patched_main())
  File "src/black/__init__.py", line 1593, in patched_main
  File "/home/hottwaj/.pyenv/versions/3.10.2/envs/test-venv/lib/python3.10/site-packages/click/core.py", line 1130, in __call__
    return self.main(*args, **kwargs)
  File "/home/hottwaj/.pyenv/versions/3.10.2/envs/test-venv/lib/python3.10/site-packages/click/core.py", line 1055, in main
    rv = self.invoke(ctx)
  File "/home/hottwaj/.pyenv/versions/3.10.2/envs/test-venv/lib/python3.10/site-packages/click/core.py", line 1404, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/home/hottwaj/.pyenv/versions/3.10.2/envs/test-venv/lib/python3.10/site-packages/click/core.py", line 760, in invoke
    return __callback(*args, **kwargs)
  File "/home/hottwaj/.pyenv/versions/3.10.2/envs/test-venv/lib/python3.10/site-packages/click/decorators.py", line 26, in new_func
    return f(get_current_context(), *args, **kwargs)
  File "src/black/__init__.py", line 703, in main
  File "/home/hottwaj/.pyenv/versions/3.10.2/envs/test-venv/lib/python3.10/site-packages/black/concurrency.py", line 101, in reformat_many
    loop.run_until_complete(
  File "/home/hottwaj/.pyenv/versions/3.10.2/lib/python3.10/asyncio/base_events.py", line 641, in run_until_complete
    return future.result()
  File "/home/hottwaj/.pyenv/versions/3.10.2/envs/test-venv/lib/python3.10/site-packages/black/concurrency.py", line 137, in schedule_formatting
    cache = Cache.read(mode)
  File "src/black/cache.py", line 67, in read
  File "/home/hottwaj/.pyenv/versions/3.10.2/lib/python3.10/pathlib.py", line 1288, in exists
    self.stat()
  File "/home/hottwaj/.pyenv/versions/3.10.2/lib/python3.10/pathlib.py", line 1095, in stat
    return self._accessor.stat(self, follow_symlinks=follow_symlinks)
OSError: [Errno 36] File name too long: '/home/hottwaj/.cache/black/24.1.0/cache.-.130.0.0.0.0.1.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.0.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.pickle'
```


**Environment**

<!-- Please complete the following information: -->

- Black's version: 24.1.0
- OS and Python version: Linux, Python 3.10.2, home folder encrypted with eCryptFS




Discussion:
Thanks for reporting. #4174 has a more detailed writeup.

I think we should do a few things:

- Merge the two hashed pieces in the cache key in one, so the name gets shorter.
- Add a test to ensure the cache filename isn't too long.
- As #4174 suggests, make it so reformatting still succeeds (with a warning) if cache creation fails.

We can put out an extra release (24.1.1) once that's done.

Resolving patch:
diff --git a/src/black/cache.py b/src/black/cache.py
--- a/src/black/cache.py
+++ b/src/black/cache.py
@@ -13,6 +13,7 @@
 
 from _black_version import version as __version__
 from black.mode import Mode
+from black.output import err
 
 if sys.version_info >= (3, 11):
     from typing import Self
@@ -64,7 +65,13 @@ def read(cls, mode: Mode) -> Self:
         resolve the issue.
         """
         cache_file = get_cache_file(mode)
-        if not cache_file.exists():
+        try:
+            exists = cache_file.exists()
+        except OSError as e:
+            # Likely file too long; see #4172 and #4174
+            err(f"Unable to read cache file {cache_file} due to {e}")
+            return cls(mode, cache_file)
+        if not exists:
             return cls(mode, cache_file)
 
         with cache_file.open("rb") as fobj:
diff --git a/src/black/mode.py b/src/black/mode.py
--- a/src/black/mode.py
+++ b/src/black/mode.py
@@ -192,6 +192,9 @@ class Deprecated(UserWarning):
     """Visible deprecation warning."""
 
 
+_MAX_CACHE_KEY_PART_LENGTH: Final = 32
+
+
 @dataclass
 class Mode:
     target_versions: Set[TargetVersion] = field(default_factory=set)
@@ -228,6 +231,19 @@ def get_cache_key(self) -> str:
             )
         else:
             version_str = "-"
+        if len(version_str) > _MAX_CACHE_KEY_PART_LENGTH:
+            version_str = sha256(version_str.encode()).hexdigest()[
+                :_MAX_CACHE_KEY_PART_LENGTH
+            ]
+        features_and_magics = (
+            ",".join(sorted(f.name for f in self.enabled_features))
+            + "@"
+            + ",".join(sorted(self.python_cell_magics))
+        )
+        if len(features_and_magics) > _MAX_CACHE_KEY_PART_LENGTH:
+            features_and_magics = sha256(features_and_magics.encode()).hexdigest()[
+                :_MAX_CACHE_KEY_PART_LENGTH
+            ]
         parts = [
             version_str,
             str(self.line_length),
@@ -236,10 +252,7 @@ def get_cache_key(self) -> str:
             str(int(self.is_ipynb)),
             str(int(self.skip_source_first_line)),
             str(int(self.magic_trailing_comma)),
-            sha256(
-                (",".join(sorted(f.name for f in self.enabled_features))).encode()
-            ).hexdigest(),
             str(int(self.preview)),
-            sha256((",".join(sorted(self.python_cell_magics))).encode()).hexdigest(),
+            features_and_magics,
         ]
         return ".".join(parts)


Test patch:
diff --git a/tests/test_black.py b/tests/test_black.py
--- a/tests/test_black.py
+++ b/tests/test_black.py
@@ -44,6 +44,7 @@
 from black import re_compile_maybe_verbose as compile_pattern
 from black.cache import FileData, get_cache_dir, get_cache_file
 from black.debug import DebugVisitor
+from black.mode import Mode, Preview
 from black.output import color_diff, diff
 from black.report import Report
 
@@ -2065,6 +2066,30 @@ def test_get_cache_dir(
         monkeypatch.setenv("BLACK_CACHE_DIR", str(workspace2))
         assert get_cache_dir().parent == workspace2
 
+    def test_cache_file_length(self) -> None:
+        cases = [
+            DEFAULT_MODE,
+            # all of the target versions
+            Mode(target_versions=set(TargetVersion)),
+            # all of the features
+            Mode(enabled_features=set(Preview)),
+            # all of the magics
+            Mode(python_cell_magics={f"magic{i}" for i in range(500)}),
+            # all of the things
+            Mode(
+                target_versions=set(TargetVersion),
+                enabled_features=set(Preview),
+                python_cell_magics={f"magic{i}" for i in range(500)},
+            ),
+        ]
+        for case in cases:
+            cache_file = get_cache_file(case)
+            # Some common file systems enforce a maximum path length
+            # of 143 (issue #4174). We can't do anything if the directory
+            # path is too long, but ensure the name of the cache file itself
+            # doesn't get too crazy.
+            assert len(cache_file.name) <= 96
+
     def test_cache_broken_file(self) -> None:
         mode = DEFAULT_MODE
         with cache_dir() as workspace: