| import os |
| import re |
| import shlex |
| import shutil |
| import sys |
| from pathlib import Path |
| from subprocess import PIPE, STDOUT, run |
| |
| ACTION_PATH = Path(os.environ["GITHUB_ACTION_PATH"]) |
| ENV_PATH = ACTION_PATH / ".black-env" |
| ENV_BIN = ENV_PATH / ("Scripts" if sys.platform == "win32" else "bin") |
| OPTIONS = os.getenv("INPUT_OPTIONS", default="") |
| SRC = os.getenv("INPUT_SRC", default="") |
| 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" |
| OUTPUT_FILE = os.getenv("OUTPUT_FILE", default="") |
| |
| BLACK_VERSION_RE = re.compile(r"^black([^A-Z0-9._-]+.*)$", re.IGNORECASE) |
| EXTRAS_RE = re.compile(r"\[.*\]") |
| EXPORT_SUBST_FAIL_RE = re.compile(r"\$Format:.*\$") |
| |
| |
| 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: |
| # Match the two supported usages of `required-version`: |
| if "." in version: |
| return f"=={version}" |
| else: |
| return f"~={version}.0" |
| |
| arrays = [ |
| *pyproject.get("dependency-groups", {}).values(), |
| 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) -> 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": |
| print( |
| "::error::Version specifier missing for 'black' dependency in " |
| "pyproject.toml.", |
| file=sys.stderr, |
| flush=True, |
| ) |
| sys.exit(1) |
| 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 = determine_version_specifier() |
| if JUPYTER: |
| extra_deps = "[colorama,jupyter]" |
| else: |
| extra_deps = "[colorama]" |
| if version_specifier: |
| req = f"black{extra_deps}{version_specifier}" |
| else: |
| describe_name = "" |
| with open(ACTION_PATH / ".git_archival.txt", encoding="utf-8") as fp: |
| for line in fp: |
| if line.startswith("describe-name: "): |
| describe_name = line[len("describe-name: ") :].rstrip() |
| break |
| if not describe_name: |
| print("::error::Failed to detect action version.", file=sys.stderr, flush=True) |
| sys.exit(1) |
| # expected format is one of: |
| # - 23.1.0 |
| # - 23.1.0-51-g448bba7 |
| # - $Format:%(describe:tags=true,match=*[0-9]*)$ (if export-subst fails) |
| if ( |
| describe_name.count("-") < 2 |
| and EXPORT_SUBST_FAIL_RE.match(describe_name) is None |
| ): |
| # the action's commit matches a tag exactly, install exact version from PyPI |
| req = f"black{extra_deps}=={describe_name}" |
| else: |
| # the action's commit does not match any tag, install from the local git repo |
| req = f".{extra_deps}" |
| print(f"Installing {req}...", flush=True) |
| pip_proc = run( |
| [str(ENV_BIN / "python"), "-m", "pip", "install", req], |
| stdout=PIPE, |
| stderr=STDOUT, |
| encoding="utf-8", |
| cwd=ACTION_PATH, |
| ) |
| if pip_proc.returncode: |
| print(pip_proc.stdout) |
| print("::error::Failed to install Black.", file=sys.stderr, flush=True) |
| sys.exit(pip_proc.returncode) |
| |
| |
| base_cmd = [str(ENV_BIN / "black")] |
| if BLACK_ARGS: |
| # TODO: remove after a while since this is deprecated in favour of SRC + OPTIONS. |
| proc = run( |
| [*base_cmd, *shlex.split(BLACK_ARGS)], |
| stdout=PIPE, |
| stderr=STDOUT, |
| encoding="utf-8", |
| ) |
| else: |
| proc = run( |
| [*base_cmd, *shlex.split(OPTIONS), *shlex.split(SRC)], |
| stdout=PIPE, |
| stderr=STDOUT, |
| encoding="utf-8", |
| ) |
| shutil.rmtree(ENV_PATH, ignore_errors=True) |
| |
| # Write output to file if specified |
| if OUTPUT_FILE: |
| try: |
| with open(OUTPUT_FILE, "w", encoding="utf-8") as f: |
| f.write(proc.stdout) |
| print(f"Black output written to {OUTPUT_FILE}") |
| except Exception as e: |
| print(f"::error::Failed to write output to {OUTPUT_FILE}: {e}", file=sys.stderr) |
| sys.exit(1) |
| |
| print(proc.stdout) |
| sys.exit(proc.returncode) |