Repository: psf/black

Issue:
Split preview into `--preview` and `--unstable`?
Black's `--preview` mode is supposed to give a preview of what next year's stable style will look like. But for the last few years, it hasn't been able to do that job, because `--preview` includes experimental string processing, which produces numerous bugs and likely again won't make it into the 2024 stable style (#4042). Several other current preview features likely won't make it into the 2024 stable style.

I propose to add a new style, `--unstable`. The idea would be that `--preview` is only for new style features that we currently think would make it into next year's stable style, and `--unstable` is for new features that we would like to eventually stabilize, but that are currently still buggy. `--unstable` would include all `--preview` features. This way, `--preview` will truly be a preview.

Concrete rules:
- Any preview feature that leads to new crashes (e.g., stability bugs) should be demoted to `--unstable` until the issue is fixed.
- Similarly, if a preview feature is shown to lead to bad formatting in some cases (in our judgment as maintainers), we demote the feature to unstable. For example, #3555 should make us demote the related preview feature to unstable.
- New preview features merged in November or December would by default go into `--unstable`, so that there is enough time for the ecosystem to try out the new feature. We can make exceptions for sufficiently simple and uncontroversial changes. We'd move these features into `--preview` once the new year starts.

Alternative solutions considered:

- We could simply be more aggressive in deleting preview features that cause trouble. I'd personally be hesitant to do this though, especially for features that have had significant effort invested into them: it will be a lot more effort to apply them to the Black codebase again later once the original issue is fixed. However, I do think we should delete unstable features that have remained in that state for too long.
- We could move unstable features out of `--preview`, but not add a separate `--unstable` flag. Instead, we could add a private flag like `-X string_processing` to enable individual preview features. This would allow more targeted testing, but it opens the door to more configurability than I'd like, and might lead to users expecting us to continue supporting such fine-grained configuration control.


Discussion:

Resolving patch:
diff --git a/src/black/__init__.py b/src/black/__init__.py
--- a/src/black/__init__.py
+++ b/src/black/__init__.py
@@ -68,7 +68,7 @@
 from black.lines import EmptyLineTracker, LinesBlock
 from black.mode import FUTURE_FLAG_TO_FEATURE, VERSION_TO_FEATURES, Feature
 from black.mode import Mode as Mode  # re-exported
-from black.mode import TargetVersion, supports_feature
+from black.mode import Preview, TargetVersion, supports_feature
 from black.nodes import (
     STARS,
     is_number_token,
@@ -192,6 +192,13 @@ def target_version_option_callback(
     return [TargetVersion[val.upper()] for val in v]
 
 
+def enable_unstable_feature_callback(
+    c: click.Context, p: Union[click.Option, click.Parameter], v: Tuple[str, ...]
+) -> List[Preview]:
+    """Compute the features from an --enable-unstable-feature flag."""
+    return [Preview[val] for val in v]
+
+
 def re_compile_maybe_verbose(regex: str) -> Pattern[str]:
     """Compile a regular expression string in `regex`.
 
@@ -286,12 +293,6 @@ def validate_regex(
     is_flag=True,
     help="Don't use trailing commas as a reason to split lines.",
 )
-@click.option(
-    "--experimental-string-processing",
-    is_flag=True,
-    hidden=True,
-    help="(DEPRECATED and now included in --preview) Normalize string literals.",
-)
 @click.option(
     "--preview",
     is_flag=True,
@@ -300,6 +301,26 @@ def validate_regex(
         " functionality in the next major release."
     ),
 )
+@click.option(
+    "--unstable",
+    is_flag=True,
+    help=(
+        "Enable potentially disruptive style changes that have known bugs or are not"
+        " currently expected to make it into the stable style Black's next major"
+        " release. Implies --preview."
+    ),
+)
+@click.option(
+    "--enable-unstable-feature",
+    type=click.Choice([v.name for v in Preview]),
+    callback=enable_unstable_feature_callback,
+    multiple=True,
+    help=(
+        "Enable specific features included in the `--unstable` style. Requires"
+        " `--preview`. No compatibility guarantees are provided on the behavior"
+        " or existence of any unstable features."
+    ),
+)
 @click.option(
     "--check",
     is_flag=True,
@@ -490,8 +511,9 @@ def main(  # noqa: C901
     skip_source_first_line: bool,
     skip_string_normalization: bool,
     skip_magic_trailing_comma: bool,
-    experimental_string_processing: bool,
     preview: bool,
+    unstable: bool,
+    enable_unstable_feature: List[Preview],
     quiet: bool,
     verbose: bool,
     required_version: Optional[str],
@@ -517,6 +539,14 @@ def main(  # noqa: C901
         out(main.get_usage(ctx) + "\n\nOne of 'SRC' or 'code' is required.")
         ctx.exit(1)
 
+    # It doesn't do anything if --unstable is also passed, so just allow it.
+    if enable_unstable_feature and not (preview or unstable):
+        out(
+            main.get_usage(ctx)
+            + "\n\n'--enable-unstable-feature' requires '--preview'."
+        )
+        ctx.exit(1)
+
     root, method = (
         find_project_root(src, stdin_filename) if code is None else (None, None)
     )
@@ -578,9 +608,10 @@ def main(  # noqa: C901
         skip_source_first_line=skip_source_first_line,
         string_normalization=not skip_string_normalization,
         magic_trailing_comma=not skip_magic_trailing_comma,
-        experimental_string_processing=experimental_string_processing,
         preview=preview,
+        unstable=unstable,
         python_cell_magics=set(python_cell_magics),
+        enabled_features=set(enable_unstable_feature),
     )
 
     lines: List[Tuple[int, int]] = []
diff --git a/src/black/mode.py b/src/black/mode.py
--- a/src/black/mode.py
+++ b/src/black/mode.py
@@ -9,7 +9,6 @@
 from hashlib import sha256
 from operator import attrgetter
 from typing import Dict, Final, Set
-from warnings import warn
 
 from black.const import DEFAULT_LINE_LENGTH
 
@@ -179,6 +178,16 @@ class Preview(Enum):
     multiline_string_handling = auto()
 
 
+UNSTABLE_FEATURES: Set[Preview] = {
+    # Many issues, see summary in https://github.com/psf/black/issues/4042
+    Preview.string_processing,
+    # See issues #3452 and #4158
+    Preview.wrap_long_dict_values_in_parens,
+    # See issue #4159
+    Preview.multiline_string_handling,
+}
+
+
 class Deprecated(UserWarning):
     """Visible deprecation warning."""
 
@@ -192,28 +201,24 @@ class Mode:
     is_ipynb: bool = False
     skip_source_first_line: bool = False
     magic_trailing_comma: bool = True
-    experimental_string_processing: bool = False
     python_cell_magics: Set[str] = field(default_factory=set)
     preview: bool = False
-
-    def __post_init__(self) -> None:
-        if self.experimental_string_processing:
-            warn(
-                "`experimental string processing` has been included in `preview`"
-                " and deprecated. Use `preview` instead.",
-                Deprecated,
-            )
+    unstable: bool = False
+    enabled_features: Set[Preview] = field(default_factory=set)
 
     def __contains__(self, feature: Preview) -> bool:
         """
         Provide `Preview.FEATURE in Mode` syntax that mirrors the ``preview`` flag.
 
-        The argument is not checked and features are not differentiated.
-        They only exist to make development easier by clarifying intent.
+        In unstable mode, all features are enabled. In preview mode, all features
+        except those in UNSTABLE_FEATURES are enabled. Any features in
+        `self.enabled_features` are also enabled.
         """
-        if feature is Preview.string_processing:
-            return self.preview or self.experimental_string_processing
-        return self.preview
+        if self.unstable:
+            return True
+        if feature in self.enabled_features:
+            return True
+        return self.preview and feature not in UNSTABLE_FEATURES
 
     def get_cache_key(self) -> str:
         if self.target_versions:
@@ -231,7 +236,9 @@ def get_cache_key(self) -> str:
             str(int(self.is_ipynb)),
             str(int(self.skip_source_first_line)),
             str(int(self.magic_trailing_comma)),
-            str(int(self.experimental_string_processing)),
+            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(),
         ]
diff --git a/src/blackd/__init__.py b/src/blackd/__init__.py
--- a/src/blackd/__init__.py
+++ b/src/blackd/__init__.py
@@ -8,6 +8,7 @@
 
 try:
     from aiohttp import web
+    from multidict import MultiMapping
 
     from .middlewares import cors
 except ImportError as ie:
@@ -34,6 +35,8 @@
 SKIP_STRING_NORMALIZATION_HEADER = "X-Skip-String-Normalization"
 SKIP_MAGIC_TRAILING_COMMA = "X-Skip-Magic-Trailing-Comma"
 PREVIEW = "X-Preview"
+UNSTABLE = "X-Unstable"
+ENABLE_UNSTABLE_FEATURE = "X-Enable-Unstable-Feature"
 FAST_OR_SAFE_HEADER = "X-Fast-Or-Safe"
 DIFF_HEADER = "X-Diff"
 
@@ -45,6 +48,8 @@
     SKIP_STRING_NORMALIZATION_HEADER,
     SKIP_MAGIC_TRAILING_COMMA,
     PREVIEW,
+    UNSTABLE,
+    ENABLE_UNSTABLE_FEATURE,
     FAST_OR_SAFE_HEADER,
     DIFF_HEADER,
 ]
@@ -53,6 +58,10 @@
 BLACK_VERSION_HEADER = "X-Black-Version"
 
 
+class HeaderError(Exception):
+    pass
+
+
 class InvalidVariantHeader(Exception):
     pass
 
@@ -93,55 +102,21 @@ async def handle(request: web.Request, executor: Executor) -> web.Response:
             return web.Response(
                 status=501, text="This server only supports protocol version 1"
             )
-        try:
-            line_length = int(
-                request.headers.get(LINE_LENGTH_HEADER, black.DEFAULT_LINE_LENGTH)
-            )
-        except ValueError:
-            return web.Response(status=400, text="Invalid line length header value")
 
-        if PYTHON_VARIANT_HEADER in request.headers:
-            value = request.headers[PYTHON_VARIANT_HEADER]
-            try:
-                pyi, versions = parse_python_variant_header(value)
-            except InvalidVariantHeader as e:
-                return web.Response(
-                    status=400,
-                    text=f"Invalid value for {PYTHON_VARIANT_HEADER}: {e.args[0]}",
-                )
-        else:
-            pyi = False
-            versions = set()
-
-        skip_string_normalization = bool(
-            request.headers.get(SKIP_STRING_NORMALIZATION_HEADER, False)
-        )
-        skip_magic_trailing_comma = bool(
-            request.headers.get(SKIP_MAGIC_TRAILING_COMMA, False)
-        )
-        skip_source_first_line = bool(
-            request.headers.get(SKIP_SOURCE_FIRST_LINE, False)
-        )
-        preview = bool(request.headers.get(PREVIEW, False))
         fast = False
         if request.headers.get(FAST_OR_SAFE_HEADER, "safe") == "fast":
             fast = True
-        mode = black.FileMode(
-            target_versions=versions,
-            is_pyi=pyi,
-            line_length=line_length,
-            skip_source_first_line=skip_source_first_line,
-            string_normalization=not skip_string_normalization,
-            magic_trailing_comma=not skip_magic_trailing_comma,
-            preview=preview,
-        )
+        try:
+            mode = parse_mode(request.headers)
+        except HeaderError as e:
+            return web.Response(status=400, text=e.args[0])
         req_bytes = await request.content.read()
         charset = request.charset if request.charset is not None else "utf8"
         req_str = req_bytes.decode(charset)
         then = datetime.now(timezone.utc)
 
         header = ""
-        if skip_source_first_line:
+        if mode.skip_source_first_line:
             first_newline_position: int = req_str.find("\n") + 1
             header = req_str[:first_newline_position]
             req_str = req_str[first_newline_position:]
@@ -190,6 +165,57 @@ async def handle(request: web.Request, executor: Executor) -> web.Response:
         return web.Response(status=500, headers=headers, text=str(e))
 
 
+def parse_mode(headers: MultiMapping[str]) -> black.Mode:
+    try:
+        line_length = int(headers.get(LINE_LENGTH_HEADER, black.DEFAULT_LINE_LENGTH))
+    except ValueError:
+        raise HeaderError("Invalid line length header value") from None
+
+    if PYTHON_VARIANT_HEADER in headers:
+        value = headers[PYTHON_VARIANT_HEADER]
+        try:
+            pyi, versions = parse_python_variant_header(value)
+        except InvalidVariantHeader as e:
+            raise HeaderError(
+                f"Invalid value for {PYTHON_VARIANT_HEADER}: {e.args[0]}",
+            ) from None
+    else:
+        pyi = False
+        versions = set()
+
+    skip_string_normalization = bool(
+        headers.get(SKIP_STRING_NORMALIZATION_HEADER, False)
+    )
+    skip_magic_trailing_comma = bool(headers.get(SKIP_MAGIC_TRAILING_COMMA, False))
+    skip_source_first_line = bool(headers.get(SKIP_SOURCE_FIRST_LINE, False))
+
+    preview = bool(headers.get(PREVIEW, False))
+    unstable = bool(headers.get(UNSTABLE, False))
+    enable_features: Set[black.Preview] = set()
+    enable_unstable_features = headers.get(ENABLE_UNSTABLE_FEATURE, "").split(",")
+    for piece in enable_unstable_features:
+        piece = piece.strip()
+        if piece:
+            try:
+                enable_features.add(black.Preview[piece])
+            except KeyError:
+                raise HeaderError(
+                    f"Invalid value for {ENABLE_UNSTABLE_FEATURE}: {piece}",
+                ) from None
+
+    return black.FileMode(
+        target_versions=versions,
+        is_pyi=pyi,
+        line_length=line_length,
+        skip_source_first_line=skip_source_first_line,
+        string_normalization=not skip_string_normalization,
+        magic_trailing_comma=not skip_magic_trailing_comma,
+        preview=preview,
+        unstable=unstable,
+        enabled_features=enable_features,
+    )
+
+
 def parse_python_variant_header(value: str) -> Tuple[bool, Set[black.TargetVersion]]:
     if value == "pyi":
         return True, set()


Test patch:
diff --git a/tests/data/cases/preview_cantfit.py b/tests/data/cases/preview_cantfit.py
--- a/tests/data/cases/preview_cantfit.py
+++ b/tests/data/cases/preview_cantfit.py
@@ -20,12 +20,6 @@
 normal_name = but_the_function_name_is_now_ridiculously_long_and_it_is_still_super_annoying(
     [1, 2, 3], arg1, [1, 2, 3], arg2, [1, 2, 3], arg3
 )
-# long arguments
-normal_name = normal_function_name(
-    "but with super long string arguments that on their own exceed the line limit so there's no way it can ever fit",
-    "eggs with spam and eggs and spam with eggs with spam and eggs and spam with eggs with spam and eggs and spam with eggs",
-    this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it=0,
-)
 string_variable_name = (
     "a string that is waaaaaaaayyyyyyyy too long, even in parens, there's nothing you can do"  # noqa
 )
@@ -78,14 +72,6 @@
         [1, 2, 3], arg1, [1, 2, 3], arg2, [1, 2, 3], arg3
     )
 )
-# long arguments
-normal_name = normal_function_name(
-    "but with super long string arguments that on their own exceed the line limit so"
-    " there's no way it can ever fit",
-    "eggs with spam and eggs and spam with eggs with spam and eggs and spam with eggs"
-    " with spam and eggs and spam with eggs",
-    this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it=0,
-)
 string_variable_name = "a string that is waaaaaaaayyyyyyyy too long, even in parens, there's nothing you can do"  # noqa
 for key in """
     hostname
diff --git a/tests/data/cases/preview_cantfit_string.py b/tests/data/cases/preview_cantfit_string.py
new file mode 100644
--- /dev/null
+++ b/tests/data/cases/preview_cantfit_string.py
@@ -0,0 +1,18 @@
+# flags: --unstable
+# long arguments
+normal_name = normal_function_name(
+    "but with super long string arguments that on their own exceed the line limit so there's no way it can ever fit",
+    "eggs with spam and eggs and spam with eggs with spam and eggs and spam with eggs with spam and eggs and spam with eggs",
+    this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it=0,
+)
+
+# output
+
+# long arguments
+normal_name = normal_function_name(
+    "but with super long string arguments that on their own exceed the line limit so"
+    " there's no way it can ever fit",
+    "eggs with spam and eggs and spam with eggs with spam and eggs and spam with eggs"
+    " with spam and eggs and spam with eggs",
+    this_is_a_ridiculously_long_name_and_nobody_in_their_right_mind_would_use_one_like_it=0,
+)
diff --git a/tests/data/cases/preview_comments7.py b/tests/data/cases/preview_comments7.py
--- a/tests/data/cases/preview_comments7.py
+++ b/tests/data/cases/preview_comments7.py
@@ -1,4 +1,4 @@
-# flags: --preview
+# flags: --unstable
 from .config import (
     Any,
     Bool,
diff --git a/tests/data/cases/preview_long_dict_values.py b/tests/data/cases/preview_long_dict_values.py
--- a/tests/data/cases/preview_long_dict_values.py
+++ b/tests/data/cases/preview_long_dict_values.py
@@ -1,4 +1,4 @@
-# flags: --preview
+# flags: --unstable
 my_dict = {
     "something_something":
         r"Lorem ipsum dolor sit amet, an sed convenire eloquentiam \t"
diff --git a/tests/data/cases/preview_long_strings.py b/tests/data/cases/preview_long_strings.py
--- a/tests/data/cases/preview_long_strings.py
+++ b/tests/data/cases/preview_long_strings.py
@@ -1,4 +1,4 @@
-# flags: --preview
+# flags: --unstable
 x = "This is a really long string that can't possibly be expected to fit all together on one line. In fact it may even take up three or more lines... like four or five... but probably just three."
 
 x += "This is a really long string that can't possibly be expected to fit all together on one line. In fact it may even take up three or more lines... like four or five... but probably just three."
diff --git a/tests/data/cases/preview_long_strings__east_asian_width.py b/tests/data/cases/preview_long_strings__east_asian_width.py
--- a/tests/data/cases/preview_long_strings__east_asian_width.py
+++ b/tests/data/cases/preview_long_strings__east_asian_width.py
@@ -1,4 +1,4 @@
-# flags: --preview
+# flags: --unstable
 # The following strings do not have not-so-many chars, but are long enough
 # when these are rendered in a monospace font (if the renderer respects
 # Unicode East Asian Width properties).
diff --git a/tests/data/cases/preview_long_strings__edge_case.py b/tests/data/cases/preview_long_strings__edge_case.py
--- a/tests/data/cases/preview_long_strings__edge_case.py
+++ b/tests/data/cases/preview_long_strings__edge_case.py
@@ -1,4 +1,4 @@
-# flags: --preview
+# flags: --unstable
 some_variable = "This string is long but not so long that it needs to be split just yet"
 some_variable = 'This string is long but not so long that it needs to be split just yet'
 some_variable = "This string is long, just long enough that it needs to be split, u get?"
diff --git a/tests/data/cases/preview_long_strings__regression.py b/tests/data/cases/preview_long_strings__regression.py
--- a/tests/data/cases/preview_long_strings__regression.py
+++ b/tests/data/cases/preview_long_strings__regression.py
@@ -1,4 +1,4 @@
-# flags: --preview
+# flags: --unstable
 class A:
     def foo():
         result = type(message)("")
diff --git a/tests/data/cases/preview_multiline_strings.py b/tests/data/cases/preview_multiline_strings.py
--- a/tests/data/cases/preview_multiline_strings.py
+++ b/tests/data/cases/preview_multiline_strings.py
@@ -1,4 +1,4 @@
-# flags: --preview
+# flags: --unstable
 """cow
 say""",
 call(3, "dogsay", textwrap.dedent("""dove
diff --git a/tests/data/cases/preview_return_annotation_brackets_string.py b/tests/data/cases/preview_return_annotation_brackets_string.py
--- a/tests/data/cases/preview_return_annotation_brackets_string.py
+++ b/tests/data/cases/preview_return_annotation_brackets_string.py
@@ -1,4 +1,4 @@
-# flags: --preview
+# flags: --unstable
 # Long string example
 def frobnicate() -> "ThisIsTrulyUnreasonablyExtremelyLongClassName | list[ThisIsTrulyUnreasonablyExtremelyLongClassName]":
     pass
diff --git a/tests/test_black.py b/tests/test_black.py
--- a/tests/test_black.py
+++ b/tests/test_black.py
@@ -157,13 +157,11 @@ def test_empty_ff(self) -> None:
 
     @patch("black.dump_to_file", dump_to_stderr)
     def test_one_empty_line(self) -> None:
-        mode = black.Mode(preview=True)
         for nl in ["\n", "\r\n"]:
             source = expected = nl
-            assert_format(source, expected, mode=mode)
+            assert_format(source, expected)
 
     def test_one_empty_line_ff(self) -> None:
-        mode = black.Mode(preview=True)
         for nl in ["\n", "\r\n"]:
             expected = nl
             tmp_file = Path(black.dump_to_file(nl))
@@ -174,20 +172,13 @@ def test_one_empty_line_ff(self) -> None:
                 with open(tmp_file, "wb") as f:
                     f.write(nl.encode("utf-8"))
             try:
-                self.assertFalse(
-                    ff(tmp_file, mode=mode, write_back=black.WriteBack.YES)
-                )
+                self.assertFalse(ff(tmp_file, write_back=black.WriteBack.YES))
                 with open(tmp_file, "rb") as f:
                     actual = f.read().decode("utf-8")
             finally:
                 os.unlink(tmp_file)
             self.assertFormatEqual(expected, actual)
 
-    def test_experimental_string_processing_warns(self) -> None:
-        self.assertWarns(
-            black.mode.Deprecated, black.Mode, experimental_string_processing=True
-        )
-
     def test_piping(self) -> None:
         _, source, expected = read_data_from_file(
             PROJECT_ROOT / "src/black/__init__.py"
@@ -251,21 +242,6 @@ def test_piping_diff_with_color(self) -> None:
         self.assertIn("\033[31m", actual)
         self.assertIn("\033[0m", actual)
 
-    @patch("black.dump_to_file", dump_to_stderr)
-    def _test_wip(self) -> None:
-        source, expected = read_data("miscellaneous", "wip")
-        sys.settrace(tracefunc)
-        mode = replace(
-            DEFAULT_MODE,
-            experimental_string_processing=False,
-            target_versions={black.TargetVersion.PY38},
-        )
-        actual = fs(source, mode=mode)
-        sys.settrace(None)
-        self.assertFormatEqual(expected, actual)
-        black.assert_equivalent(source, actual)
-        black.assert_stable(source, actual, black.FileMode())
-
     def test_pep_572_version_detection(self) -> None:
         source, _ = read_data("cases", "pep_572")
         root = black.lib2to3_parse(source)
@@ -373,7 +349,7 @@ def test_detect_debug_f_strings(self) -> None:
     @patch("black.dump_to_file", dump_to_stderr)
     def test_string_quotes(self) -> None:
         source, expected = read_data("miscellaneous", "string_quotes")
-        mode = black.Mode(preview=True)
+        mode = black.Mode(unstable=True)
         assert_format(source, expected, mode)
         mode = replace(mode, string_normalization=False)
         not_normalized = fs(source, mode=mode)
@@ -1051,7 +1027,6 @@ def test_format_file_contents(self) -> None:
             black.format_file_contents(invalid, mode=mode, fast=False)
         self.assertEqual(str(e.exception), "Cannot parse: 1:7: return if you can")
 
-        mode = black.Mode(preview=True)
         just_crlf = "\r\n"
         with self.assertRaises(black.NothingChanged):
             black.format_file_contents(just_crlf, mode=mode, fast=False)
@@ -1395,7 +1370,6 @@ def get_output(*args: Any, **kwargs: Any) -> io.TextIOWrapper:
 
             return get_output
 
-        mode = black.Mode(preview=True)
         for content, expected in cases:
             output = io.StringIO()
             io_TextIOWrapper = io.TextIOWrapper
@@ -1406,26 +1380,27 @@ def get_output(*args: Any, **kwargs: Any) -> io.TextIOWrapper:
                         fast=True,
                         content=content,
                         write_back=black.WriteBack.YES,
-                        mode=mode,
+                        mode=DEFAULT_MODE,
                     )
                 except io.UnsupportedOperation:
                     pass  # StringIO does not support detach
                 assert output.getvalue() == expected
 
-        # An empty string is the only test case for `preview=False`
-        output = io.StringIO()
-        io_TextIOWrapper = io.TextIOWrapper
-        with patch("io.TextIOWrapper", _new_wrapper(output, io_TextIOWrapper)):
-            try:
-                black.format_stdin_to_stdout(
-                    fast=True,
-                    content="",
-                    write_back=black.WriteBack.YES,
-                    mode=DEFAULT_MODE,
-                )
-            except io.UnsupportedOperation:
-                pass  # StringIO does not support detach
-            assert output.getvalue() == ""
+    def test_cli_unstable(self) -> None:
+        self.invokeBlack(["--unstable", "-c", "0"], exit_code=0)
+        self.invokeBlack(["--preview", "-c", "0"], exit_code=0)
+        # Must also pass --preview
+        self.invokeBlack(
+            ["--enable-unstable-feature", "string_processing", "-c", "0"], exit_code=1
+        )
+        self.invokeBlack(
+            ["--preview", "--enable-unstable-feature", "string_processing", "-c", "0"],
+            exit_code=0,
+        )
+        self.invokeBlack(
+            ["--unstable", "--enable-unstable-feature", "string_processing", "-c", "0"],
+            exit_code=0,
+        )
 
     def test_invalid_cli_regex(self) -> None:
         for option in ["--include", "--exclude", "--extend-exclude", "--force-exclude"]:
diff --git a/tests/util.py b/tests/util.py
--- a/tests/util.py
+++ b/tests/util.py
@@ -112,16 +112,24 @@ def assert_format(
     # For both preview and non-preview tests, ensure that Black doesn't crash on
     # this code, but don't pass "expected" because the precise output may differ.
     try:
+        if mode.unstable:
+            new_mode = replace(mode, unstable=False, preview=False)
+        else:
+            new_mode = replace(mode, preview=not mode.preview)
         _assert_format_inner(
             source,
             None,
-            replace(mode, preview=not mode.preview),
+            new_mode,
             fast=fast,
             minimum_version=minimum_version,
             lines=lines,
         )
     except Exception as e:
-        text = "non-preview" if mode.preview else "preview"
+        text = (
+            "unstable"
+            if mode.unstable
+            else "non-preview" if mode.preview else "preview"
+        )
         raise FormatFailure(
             f"Black crashed formatting this case in {text} mode."
         ) from e
@@ -138,7 +146,7 @@ def assert_format(
             _assert_format_inner(
                 source,
                 None,
-                replace(mode, preview=preview_mode, line_length=1),
+                replace(mode, preview=preview_mode, line_length=1, unstable=False),
                 fast=fast,
                 minimum_version=minimum_version,
                 lines=lines,
@@ -241,6 +249,7 @@ def get_flags_parser() -> argparse.ArgumentParser:
         "--skip-magic-trailing-comma", default=False, action="store_true"
     )
     parser.add_argument("--preview", default=False, action="store_true")
+    parser.add_argument("--unstable", default=False, action="store_true")
     parser.add_argument("--fast", default=False, action="store_true")
     parser.add_argument(
         "--minimum-version",
@@ -278,6 +287,7 @@ def parse_mode(flags_line: str) -> TestCaseArgs:
         is_ipynb=args.ipynb,
         magic_trailing_comma=not args.skip_magic_trailing_comma,
         preview=args.preview,
+        unstable=args.unstable,
     )
     if args.line_ranges:
         lines = parse_line_ranges(args.line_ranges)