Repository: psf/black

Issue:
Cache causes confusing behavior when running with and without "--unstable"

**Describe the bug**

If you run `black` without `--unstable` on a file and then run it with `--unstable`, it won't apply changes that should be applied by `--unstable`.
 
<!-- A clear and concise description of what the bug is. -->

**To Reproduce**

<!--
Minimal steps to reproduce the behavior with source code and Black's configuration.
-->


```shell
black --version
python_code='long_line = "      20        30        40        50        60        70        80        90        100       110       120       130       140"'
echo "$python_code" > my_file.py
echo "Formatting without --unstable..."
black -l 120 my_file.py
cat my_file.py

echo "Formatting with --unstable..."
black -l 120 --unstable my_file.py
cat my_file.py
echo "Adding a newline to invalidate the cache..."
echo -e "\n" >> my_file.py
echo "Formatting with --unstable again..."
black -l 120 --unstable my_file.py
cat my_file.py
```
for me outputs

```
black, 24.8.0 (compiled: yes)
Python (CPython) 3.12.4
Formatting without --unstable...
All done! ✨ 🍰 ✨
1 file left unchanged.
long_line = "      20        30        40        50        60        70        80        90        100       110       120       130       140"
Formatting with --unstable...
All done! ✨ 🍰 ✨
1 file left unchanged.
long_line = "      20        30        40        50        60        70        80        90        100       110       120       130       140"
Adding a newline to invalidate the cache...
Formatting with --unstable again...
reformatted my_file.py

All done! ✨ 🍰 ✨
1 file reformatted.
long_line = (
    "      20        30        40        50        60        70        80        90        100       110       120     "
    "  130       140"
)

```


**Expected behavior**

* Adding `--unstable` to the command should cause black to ignore cache results that were produced without `--unstable`.
* To me "1 file left unchanged" misleadingly suggests that `black` looked at the file and determined there were no changes to make. A wording like "1 unchanged file ignored" would have helped me understand a cache was in play.
* Similarly the fact that `black --help` makes no mention of a cache / has no flag to bypass was a barrier to me understanding that this was a possibility.

**Environment**

<!-- Please complete the following information: -->

- Black's version: 24.8.0
- OS and Python version: 3.12.4, Mac OS


Discussion:
JelleZijlstra: There's something wrong on the 3.13 CI for macOS, investigating.

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 11e491e..97e8645 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -30,6 +30,10 @@
 - Fix type annotation spacing between * and more complex type variable tuple (i.e. `def
   fn(*args: *tuple[*Ts, T]) -> None: pass`) (#4440)
 
+### Caching
+
+- Fix bug where the cache was shared between runs with and without `--unstable` (#4466)
+
 ### Configuration
 
 <!-- Changes to how Black can be configured -->
diff --git a/src/black/mode.py b/src/black/mode.py
index 452be96..02fe1de 100644
--- a/src/black/mode.py
+++ b/src/black/mode.py
@@ -290,6 +290,7 @@ def get_cache_key(self) -> str:
             str(int(self.skip_source_first_line)),
             str(int(self.magic_trailing_comma)),
             str(int(self.preview)),
+            str(int(self.unstable)),
             features_and_magics,
         ]
         return ".".join(parts)


Test patch:
diff --git a/tests/test_black.py b/tests/test_black.py
index d283ea6..c448c60 100644
--- a/tests/test_black.py
+++ b/tests/test_black.py
@@ -12,7 +12,7 @@
 import types
 from concurrent.futures import ThreadPoolExecutor
 from contextlib import contextmanager, redirect_stderr
-from dataclasses import replace
+from dataclasses import fields, replace
 from io import BytesIO
 from pathlib import Path, WindowsPath
 from platform import system
@@ -2347,6 +2347,36 @@ def test_read_cache_line_lengths(self) -> None:
             two = black.Cache.read(short_mode)
             assert two.is_changed(path)
 
+    def test_cache_key(self) -> None:
+        # Test that all members of the mode enum affect the cache key.
+        for field in fields(Mode):
+            values: List[Any]
+            if field.name == "target_versions":
+                values = [
+                    {TargetVersion.PY312},
+                    {TargetVersion.PY313},
+                ]
+            elif field.name == "python_cell_magics":
+                values = [{"magic1"}, {"magic2"}]
+            elif field.name == "enabled_features":
+                # If you are looking to remove one of these features, just
+                # replace it with any other feature.
+                values = [
+                    {Preview.docstring_check_for_newline},
+                    {Preview.hex_codes_in_unicode_sequences},
+                ]
+            elif field.type is bool:
+                values = [True, False]
+            elif field.type is int:
+                values = [1, 2]
+            else:
+                raise AssertionError(
+                    f"Unhandled field type: {field.type} for field {field.name}"
+                )
+            modes = [replace(DEFAULT_MODE, **{field.name: value}) for value in values]
+            keys = [mode.get_cache_key() for mode in modes]
+            assert len(set(keys)) == len(modes)
+
 
 def assert_collected_sources(
     src: Sequence[Union[str, Path]],
diff --git a/tests/test_blackd.py b/tests/test_blackd.py
index 3a9851a..bef5eae 100644
--- a/tests/test_blackd.py
+++ b/tests/test_blackd.py
@@ -1,3 +1,4 @@
+import gc
 import re
 from unittest.mock import patch
 
@@ -17,6 +18,11 @@
 
 @pytest.mark.blackd
 class BlackDTestCase(AioHTTPTestCase):
+    def tearDown(self) -> None:
+        # Work around https://github.com/python/cpython/issues/124706
+        gc.collect()
+        super().tearDown()
+
     def test_blackd_main(self) -> None:
         with patch("blackd.web.run_app"):
             result = CliRunner().invoke(blackd.main, [])