Repository: akaihola/darker

Issue:
Allow pinning linter packages in the GitHub Action
To keep GitHub workflows stable, users may want to pin both Darker and linter packages to fixed versions when using the GitHub Action. Darker can already be pinned using the `version:` option, but linter pinning wasn't yet added in #313.

# Alternatives for the syntax

In these examples, Pylint is pinned but the newest version of Flake8 is to be installed.

## Embed versions in `lint:` option
In this alternative, items in `lint:` need to be validated and passed to `pip install`, and command names without the version are needed for the Darker command line `--lint` options. For linters with no version number, the newest version on PyPI would be installed:

`.github/workflows/darker.yml`:
```yaml
      - uses: akaihola/darker@1.5.0
        with:
          lint: pylint==2.12.2, flake8
```

## A separate `constraints:` option
Here, versions of packages are provided in a separate `constraints:` option. If a linter is omitted from `constraints`, the newest version would be installed.

The use of this option could extend beyond just linters. We could even deprecate the `version:` option and guide users to use `constraints: darker==1.5.0` instead. And this could also solve the missing `isort` version pinning feature.

`.github/workflows/darker.yml`:
```yaml
      - uses: akaihola/darker@1.5.0
        with:
          lint: pylint, flake8
          constraints: pylint==2.12.2
```

## Constraints in a text file
We could also require the user to provide constraints in a text file and point to it in a `constraints-file:` option:

`.github/workflows/darker.yml`:
```yaml
      - uses: akaihola/darker@1.5.0
        with:
          lint: pylint, flake8
          constraints-file: .github/workflows/darker-lint-constraints.txt
```

`.github/workflows/darker-lint-contstraints.txt`:
```
pylint==2.12.2
```

# Considerations

- Is the Pip constraints feature well enough known, or would it be better to use the name `versions:` instead of `constraints:`?

Discussion:

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 9d86b75..4656aaf 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -7,7 +7,7 @@ Added
 -----
 - The ``--jobs`` option now specifies how many Darker jobs are used to process files in
   parallel to complete reformatting/linting faster.
-- Linters can now be run in the GitHub Action using the ``lint:`` option.
+- Linters can now be installed and run in the GitHub Action using the ``lint:`` option.
   
 Fixed
 -----
diff --git a/README.rst b/README.rst
index 0c92dce..6bc7fda 100644
--- a/README.rst
+++ b/README.rst
@@ -575,7 +575,7 @@ Create a file named ``.github/workflows/darker.yml`` inside your repository with
              revision: "master..."
              src: "./src"
              version: "1.4.2"
-             lint: "flake8,pylint"
+             lint: "flake8,pylint==2.13.1"
 
 There needs to be a working Python environment, set up using ``actions/setup-python``
 in the above example. Darker will be installed in an isolated virtualenv to prevent
@@ -603,6 +603,7 @@ You can e.g. add ``"--isort"`` to sort imports, or ``"--verbose"`` for debug log
 
 To run linters through Darker, you can provide a comma separated list of linters using
 the ``lint:`` option. Only ``flake8``, ``pylint`` and ``mypy`` are supported.
+Versions can be constrained using ``pip`` syntax, e.g. ``"flake8>=3.9.2"``.
 
 *New in version 1.1.0:*
 GitHub Actions integration. Modeled after how Black_ does it,
diff --git a/action.yml b/action.yml
index ae370cb..69711d0 100644
--- a/action.yml
+++ b/action.yml
@@ -25,7 +25,8 @@ inputs:
   lint:
     description: >-
       Comma-separated list of linters to `pip install` and run from Darker.
-      Example: flake8,pylint
+      Optionally, version constraints (using pip syntax) can be specified.
+      Example: flake8,pylint==2.13.1
     required: false
     default: ''
 branding:
diff --git a/action/main.py b/action/main.py
index 83c0033..30033e8 100644
--- a/action/main.py
+++ b/action/main.py
@@ -4,6 +4,8 @@ import sys
 from pathlib import Path
 from subprocess import PIPE, STDOUT, run  # nosec
 
+from pkg_resources import parse_requirements
+
 LINTER_WHITELIST = {"flake8", "pylint", "mypy"}
 ACTION_PATH = Path(os.environ["GITHUB_ACTION_PATH"])
 ENV_PATH = ACTION_PATH / ".darker-env"
@@ -11,6 +13,7 @@ ENV_BIN = ENV_PATH / ("Scripts" if sys.platform == "win32" else "bin")
 OPTIONS = os.getenv("INPUT_OPTIONS", default="")
 SRC = os.getenv("INPUT_SRC", default="")
 VERSION = os.getenv("INPUT_VERSION", default="")
+LINT = os.getenv("INPUT_LINT", default="")
 REVISION = os.getenv(
     "INPUT_REVISION", default=os.getenv("INPUT_COMMIT_RANGE", default="HEAD^")
 )
@@ -21,15 +24,13 @@ req = ["darker[isort]"]
 if VERSION:
     req[0] += f"=={VERSION}"
 linter_options = []
-for linter_ in os.getenv("INPUT_LINT", default="").split(","):
-    linter = linter_.strip()
-    if not linter:
-        continue
+for linter_requirement in parse_requirements(LINT.replace(",", "\n")):
+    linter = linter_requirement.name
     if linter not in LINTER_WHITELIST:
         raise RuntimeError(
             f"{linter!r} is not supported as a linter by the GitHub Action"
         )
-    req.append(linter)
+    req.append(str(linter_requirement))
     linter_options.extend(["--lint", linter])
 
 pip_proc = run(  # nosec


Test patch:
diff --git a/action/tests/test_main.py b/action/tests/test_main.py
index 6b43519..8f4fe5d 100644
--- a/action/tests/test_main.py
+++ b/action/tests/test_main.py
@@ -103,6 +103,10 @@ def test_creates_virtualenv(tmp_path, main_patch):
         run_main_env={"INPUT_LINT": "  flake8  ,  pylint  "},
         expect=["darker[isort]", "flake8", "pylint"],
     ),
+    dict(
+        run_main_env={"INPUT_LINT": "  flake8  >=  3.9.2  ,  pylint  ==  2.13.1  "},
+        expect=["darker[isort]", "flake8>=3.9.2", "pylint==2.13.1"],
+    ),
 )
 def test_installs_packages(tmp_path, main_patch, run_main_env, expect):
     """Darker, isort and linters are installed in the virtualenv using pip"""
@@ -126,7 +130,7 @@ def test_installs_packages(tmp_path, main_patch, run_main_env, expect):
 
 
 @pytest.mark.parametrize(
-    "linters", ["foo", "  foo  ", "foo,bar", "  foo  ,  bar  ", "pylint,foo"]
+    "linters", ["foo", "  foo  ", "foo==2.0,bar", "  foo>1.0  ,  bar  ", "pylint,foo"]
 )
 def test_wont_install_unknown_packages(tmp_path, linters):
     """Non-whitelisted linters raise an exception"""
@@ -173,6 +177,10 @@ def test_wont_install_unknown_packages(tmp_path, linters):
         env={"INPUT_SRC": ".", "INPUT_LINT": "pylint,flake8"},
         expect=["--lint", "pylint", "--lint", "flake8", "--revision", "HEAD^", "."],
     ),
+    dict(
+        env={"INPUT_SRC": ".", "INPUT_LINT": "pylint == 2.13.1,flake8>=3.9.2"},
+        expect=["--lint", "pylint", "--lint", "flake8", "--revision", "HEAD^", "."],
+    ),
     dict(
         env={
             "INPUT_SRC": "here.py there/too",