Repository: psf/black

Issue:
Feature Request - add required black version in pyproject.toml
Many projects that I contribute to have adopted black. (Congrats and Thank You!)

An issue which has come up in multiple projects is automatic tests failing because of differences in the installed `black` versions between environments.

For example: a project may indicate a particular version of black in a pre-commit configuration in `.pre-commit-config.yaml` (https://black.readthedocs.io/en/stable/version_control_integration.html) which will re-format the code to that version... but that will not-necessarily be the same version which is invoked by the local developer or the build/deploy service - as black will consult the `pyproject.toml` file instead (https://black.readthedocs.io/en/stable/pyproject_toml.html).

This can create issues on automated tests, because the server environment may be configured to install `19.3b0` while the hook or developer's environment is formatting to `19.10b0`.  This can suggest a developer didn't run `black` or made an error, when the build/deploy system is what requires adjustment.

A potential solution is to extend the `pyproject.toml` configuration to have an argument which states the required version:

    [tool.black]
    line-length = 88
    rev = 19.10b0

If specified, black would then raise a fatal exception if invoked with a version that does not match. Perhaps a commandline flag is needed to avoid this for certain causes.  Some projects specify a specific version of black for a particular test environment in `tox.ini`, but nothing is global.

An alternative might also include migrating the version declaration from the `.pre-commit-config.yaml` into the `pyproject.toml` file, where it seems to be more applicable.  However, I assume the revision was declared in this file for important reasons I do not understand yet.

To summarize:

* pre-config integration is great, but if a contributor doesn't have pre-commit installed, they'll invoke black directly
* if a contributor invokes black directly, they may format the code with a different version than the project uses
* automated systems can pick up a new version of black; by may not pin the version in the tox config for the test. or they use travis.

having a centralized version pin, instead of requiring a version pin for each context, would eliminate many problems.

Thank you.

see also: #420




Discussion:
I guess poetry can solve this. try convince your team to use poetry. I don't know any other solutions than this
I agree that this is a useful feature and I'd welcome a PR implementing it. Sorry for the lack of response earlier.

Resolving patch:
diff --git a/src/black/__init__.py b/src/black/__init__.py
--- a/src/black/__init__.py
+++ b/src/black/__init__.py
@@ -241,6 +241,14 @@ def validate_regex(
     is_flag=True,
     help="If --fast given, skip temporary sanity checks. [default: --safe]",
 )
+@click.option(
+    "--required-version",
+    type=str,
+    help=(
+        "Require a specific version of Black to be running (useful for unifying results"
+        " across many environments e.g. with a pyproject.toml file)."
+    ),
+)
 @click.option(
     "--include",
     type=str,
@@ -351,6 +359,7 @@ def main(
     experimental_string_processing: bool,
     quiet: bool,
     verbose: bool,
+    required_version: str,
     include: Pattern,
     exclude: Optional[Pattern],
     extend_exclude: Optional[Pattern],
@@ -360,6 +369,17 @@ def main(
     config: Optional[str],
 ) -> None:
     """The uncompromising code formatter."""
+    if config and verbose:
+        out(f"Using configuration from {config}.", bold=False, fg="blue")
+
+    error_msg = "Oh no! 💥 💔 💥"
+    if required_version and required_version != __version__:
+        err(
+            f"{error_msg} The required version `{required_version}` does not match"
+            f" the running version `{__version__}`!"
+        )
+        ctx.exit(1)
+
     write_back = WriteBack.from_configuration(check=check, diff=diff, color=color)
     if target_version:
         versions = set(target_version)
@@ -374,8 +394,6 @@ def main(
         magic_trailing_comma=not skip_magic_trailing_comma,
         experimental_string_processing=experimental_string_processing,
     )
-    if config and verbose:
-        out(f"Using configuration from {config}.", bold=False, fg="blue")
 
     if code is not None:
         # Run in quiet mode by default with -c; the extra output isn't useful.
@@ -428,9 +446,9 @@ def main(
             )
 
     if verbose or not quiet:
-        out("Oh no! 💥 💔 💥" if report.return_code else "All done! ✨ 🍰 ✨")
+        out(error_msg if report.return_code else "All done! ✨ 🍰 ✨")
         if code is None:
-            click.secho(str(report), err=True)
+            click.echo(str(report), err=True)
     ctx.exit(report.return_code)
 
 


Test patch:
diff --git a/tests/test_black.py b/tests/test_black.py
--- a/tests/test_black.py
+++ b/tests/test_black.py
@@ -1796,6 +1796,16 @@ def test_invalid_cli_regex(self) -> None:
         for option in ["--include", "--exclude", "--extend-exclude", "--force-exclude"]:
             self.invokeBlack(["-", option, "**()(!!*)"], exit_code=2)
 
+    def test_required_version_matches_version(self) -> None:
+        self.invokeBlack(
+            ["--required-version", black.__version__], exit_code=0, ignore_config=True
+        )
+
+    def test_required_version_does_not_match_version(self) -> None:
+        self.invokeBlack(
+            ["--required-version", "20.99b"], exit_code=1, ignore_config=True
+        )
+
     def test_preserves_line_endings(self) -> None:
         with TemporaryDirectory() as workspace:
             test_file = Path(workspace) / "test.py"