Repository: psf/black
Pull request: GitHub Action: Allow reading version from pyproject.toml
### Description

This adds an `use_pyproject` input to the GitHub Action that – when set to `true` – will read the black version from pyproject.toml file. Either from the `project.dependencies` array or one of the arrays from the `project.optional-dependencies` table.

Closes: #4285

### Checklist - did you ...

<!-- If any of the following items aren't relevant for your contribution
     please still tick them so we know you've gone through the checklist.

    All user-facing changes should get an entry. Otherwise, signal to us
    this should get the magical label to silence the CHANGELOG entry check.
    Tests are required for bugfixes and new features. Documentation changes
    are necessary for formatting and most enhancement changes. -->

- [ ] Add an entry in `CHANGES.md` if necessary?
- [ ] Add / update tests if necessary?
- [x] Add new / update outdated documentation?

Trial runs at: https://github.com/srittau/python-asserts/actions/runs/8490172420, using this branch of one of my projects: https://github.com/srittau/python-asserts/tree/black-test


Problem statement:
GitHub Action: Option to parse version from pyproject.toml
**Is your feature request related to a problem? Please describe.**

When using black's GitHub action "psf/black", you need to specify the black version to use manually as an option to the action, in addition to specifying it in pyproject.toml. This can easily lead to inconsistencies between the files, especially when using dependabot to keep the version current.

**Describe the solution you'd like**

It would be useful if the action could read the version to use from pyproject.toml. This would probably need a new option like "pyproject: true" to enable this behavior.

**Describe alternatives you've considered**

An alternative is to read pyproject.toml in a previous step in a GitHub workflow for example using SebRollen/toml-action, and provide the read value using a variable, but just using a flag to this action would be much easier and less error prone.

Hints from issue discussion:

Commit message: [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Patch to review:
diff --git a/action.yml b/action.yml
index 49cd6b06e4c..d49dc4ac3a0 100644
--- a/action.yml
+++ b/action.yml
@@ -27,6 +27,10 @@ inputs:
     description: 'Python Version specifier (PEP440) - e.g. "21.5b1"'
     required: false
     default: ""
+  use_pyproject:
+    description: Read Black version specifier from pyproject.toml if `true`.
+    required: false
+    default: "false"
   summary:
     description: "Whether to add the output to the workflow summary"
     required: false
@@ -70,5 +74,6 @@ runs:
         INPUT_JUPYTER: ${{ inputs.jupyter }}
         INPUT_BLACK_ARGS: ${{ inputs.black_args }}
         INPUT_VERSION: ${{ inputs.version }}
+        INPUT_USE_PYPROJECT: ${{ inputs.use_pyproject }}
         pythonioencoding: utf-8
       shell: bash
diff --git a/action/main.py b/action/main.py
index c0af3930dbb..1347007fc01 100644
--- a/action/main.py
+++ b/action/main.py
@@ -1,9 +1,11 @@
 import os
+import re
 import shlex
 import shutil
 import sys
 from pathlib import Path
 from subprocess import PIPE, STDOUT, run
+from typing import Union
 
 ACTION_PATH = Path(os.environ["GITHUB_ACTION_PATH"])
 ENV_PATH = ACTION_PATH / ".black-env"
@@ -13,12 +15,101 @@
 JUPYTER = os.getenv("INPUT_JUPYTER") == "true"
 BLACK_ARGS = os.getenv("INPUT_BLACK_ARGS", default="")
 VERSION = os.getenv("INPUT_VERSION", default="")
+USE_PYPROJECT = os.getenv("INPUT_USE_PYPROJECT") == "true"
+
+BLACK_VERSION_RE = re.compile(r"^black([^A-Z0-9._-]+.*)$", re.IGNORECASE)
+EXTRAS_RE = re.compile(r"\[.*\]")
+
+
+def determine_version_specifier() -> str:
+    """Determine the version of Black to install.
+
+    The version can be specified either via the `with.version` input or via the
+    pyproject.toml file if `with.use_pyproject` is set to `true`.
+    """
+    if USE_PYPROJECT and VERSION:
+        print(
+            "::error::'with.version' and 'with.use_pyproject' inputs are "
+            "mutually exclusive.",
+            file=sys.stderr,
+            flush=True,
+        )
+        sys.exit(1)
+    if USE_PYPROJECT:
+        return read_version_specifier_from_pyproject()
+    elif VERSION and VERSION[0] in "0123456789":
+        return f"=={VERSION}"
+    else:
+        return VERSION
+
+
+def read_version_specifier_from_pyproject() -> str:
+    if sys.version_info < (3, 11):
+        print(
+            "::error::'with.use_pyproject' input requires Python 3.11 or later.",
+            file=sys.stderr,
+            flush=True,
+        )
+        sys.exit(1)
+
+    import tomllib  # type: ignore[import-not-found,unreachable]
+
+    try:
+        with Path("pyproject.toml").open("rb") as fp:
+            pyproject = tomllib.load(fp)
+    except FileNotFoundError:
+        print(
+            "::error::'with.use_pyproject' input requires a pyproject.toml file.",
+            file=sys.stderr,
+            flush=True,
+        )
+        sys.exit(1)
+
+    version = pyproject.get("tool", {}).get("black", {}).get("required-version")
+    if version is not None:
+        return f"=={version}"
+
+    arrays = [
+        pyproject.get("project", {}).get("dependencies"),
+        *pyproject.get("project", {}).get("optional-dependencies", {}).values(),
+    ]
+    for array in arrays:
+        version = find_black_version_in_array(array)
+        if version is not None:
+            break
+
+    if version is None:
+        print(
+            "::error::'black' dependency missing from pyproject.toml.",
+            file=sys.stderr,
+            flush=True,
+        )
+        sys.exit(1)
+
+    return version
+
+
+def find_black_version_in_array(array: object) -> Union[str, None]:
+    if not isinstance(array, list):
+        return None
+    try:
+        for item in array:
+            # Rudimentary PEP 508 parsing.
+            item = item.split(";")[0]
+            item = EXTRAS_RE.sub("", item).strip()
+            if item == "black":
+                return ""
+            elif m := BLACK_VERSION_RE.match(item):
+                return m.group(1).strip()
+    except TypeError:
+        pass
+
+    return None
+
 
 run([sys.executable, "-m", "venv", str(ENV_PATH)], check=True)
 
-version_specifier = VERSION
-if VERSION and VERSION[0] in "0123456789":
-    version_specifier = f"=={VERSION}"
+version_specifier = determine_version_specifier()
 if JUPYTER:
     extra_deps = "[colorama,jupyter]"
 else:
diff --git a/docs/integrations/github_actions.md b/docs/integrations/github_actions.md
index 56b2cdd0586..4ba03b5faac 100644
--- a/docs/integrations/github_actions.md
+++ b/docs/integrations/github_actions.md
@@ -32,12 +32,15 @@ We recommend the use of the `@stable` tag, but per version tags also exist if yo
 that. Note that the action's version you select is independent of the version of _Black_
 the action will use.
 
-The version of _Black_ the action will use can be configured via `version`. This can be
-any
+The version of _Black_ the action will use can be configured via `version` or read from
+the `pyproject.toml` file. `version` can be any
 [valid version specifier](https://packaging.python.org/en/latest/glossary/#term-Version-Specifier)
-or just the version number if you want an exact version. The action defaults to the
-latest release available on PyPI. Only versions available from PyPI are supported, so no
-commit SHAs or branch names.
+or just the version number if you want an exact version. To read the version from the
+`pyproject.toml` file instead, set `use_pyproject` to `true`. This will first look into
+the `tool.black.required-version` field, then the `project.dependencies` array and
+finally the `project.optional-dependencies` table. The action defaults to the latest
+release available on PyPI. Only versions available from PyPI are supported, so no commit
+SHAs or branch names.
 
 If you want to include Jupyter Notebooks, _Black_ must be installed with the `jupyter`
 extra. Installing the extra and including Jupyter Notebook files can be configured via
@@ -70,3 +73,13 @@ If you want to match versions covered by Black's
     src: "./src"
     version: "~= 22.0"
 ```
+
+If you want to read the version from `pyproject.toml`, set `use_pyproject` to `true`:
+
+```yaml
+- uses: psf/black@stable
+  with:
+    options: "--check --verbose"
+    src: "./src"
+    use_pyproject: true
+```


Code review comments:
diff_hunk: @@ -13,12 +15,101 @@
 JUPYTER = os.getenv("INPUT_JUPYTER") == "true"
 BLACK_ARGS = os.getenv("INPUT_BLACK_ARGS", default="")
 VERSION = os.getenv("INPUT_VERSION", default="")
+USE_PYPROJECT = os.getenv("INPUT_USE_PYPROJECT") == "true"
+
+BLACK_VERSION_RE = re.compile(r"^black([^A-Z0-9._-]+.*)$", re.IGNORECASE)
+EXTRAS_RE = re.compile(r"\[.*\]")
+
+
+def determine_version_specifier() -> str:
+    """Determine the version of Black to install.
+
+    The version can be specified either via the `with.version` input or via the
+    pyproject.toml file if `with.use_pyproject` is set to `true`.
+    """
+    if USE_PYPROJECT and VERSION:
+        print(
+            "::error::'with.version' and 'with.use_pyproject' inputs are "
+            "mutually exclusive.",
+            file=sys.stderr,
+            flush=True,
+        )
+        sys.exit(1)
+    if USE_PYPROJECT:
+        return read_version_specifier_from_pyproject()
+    elif VERSION and VERSION[0] in "0123456789":
+        return f"=={VERSION}"
+    else:
+        return VERSION
+
+
+def read_version_specifier_from_pyproject() -> str:
+    if sys.version_info < (3, 11):
+        print(
+            "::error::'with.use_pyproject' input requires Python 3.11 or later.",
+            file=sys.stderr,
+            flush=True,
+        )
+        sys.exit(1)
+
+    import tomllib  # type: ignore[import-not-found,unreachable]
+
+    try:
+        with Path("pyproject.toml").open("rb") as fp:
+            pyproject = tomllib.load(fp)
+    except FileNotFoundError:
+        print(
+            "::error::'with.use_pyproject' input requires a pyproject.toml file.",
+            file=sys.stderr,
+            flush=True,
+        )
+        sys.exit(1)
+
+    version = pyproject.get("tool", {}).get("black", {}).get("required-version")
+    if version is not None:
+        return f"=={version}"
+
+    arrays = [
+        pyproject.get("project", {}).get("dependencies"),
+        *pyproject.get("project", {}).get("optional-dependencies", {}).values(),
+    ]
+    for array in arrays:
+        version = find_black_version_in_array(array)
+        if version is not None:
+            break
+
+    if version is None:
+        print(
+            "::error::'black' dependency missing from pyproject.toml.",
+            file=sys.stderr,
+            flush=True,
+        )
+        sys.exit(1)
+
+    return version
+
+
+def find_black_version_in_array(array: object) -> Union[str, None]:
+    if not isinstance(array, list):
+        return None
+    try:
+        for item in array:
+            # Rudimentary PEP 508 parsing.
+            item = item.split(";")[0]
+            item = EXTRAS_RE.sub("", item).strip()
+            if item == "black":
+                return ""
path: action/main.py
text: @user1:
Would it make more sense to continue here? It looks like below we ultimately end up ignoring the empty string anyway and using the action version instead.

@author:
If you trace it all the way back to `read_version_specifier_from_pyproject()`, there is a difference: Due to returning `""`, we don't generate an error in the action, but use the action's default behavior (use the latest version). If we'd return `None`, this would cause an error when using just `black` as a requirement.

@user1:
Maybe showing an error is the right behavior? In this situation, the user set `use_pyproject` to true, meaning they expect us to infer the version from pyproject.toml, but we aren't able to do so.