diff --git a/.github/workflows/format-and-lint.yaml b/.github/workflows/format-and-lint.yaml index dbd4733..0cc2bfa 100644 --- a/.github/workflows/format-and-lint.yaml +++ b/.github/workflows/format-and-lint.yaml @@ -17,7 +17,7 @@ jobs: # Set the ruff config in the .github repo as the global config, then the local config should extend it by including the line `extend = "~/.config/ruff/ruff.toml"` mkdir -p ~/.config/ruff - url=https://raw.githubusercontent.com/mdolab/.github/main/ruff.toml + url=https://raw.githubusercontent.com/mdolab/.github/main/pre-commit/_config/ruff.toml wget $url -O ~/.config/ruff/ruff.toml echo "Ruff config:" diff --git a/.github/workflows/isort.yaml b/.github/workflows/isort.yaml index 64d784b..08e5856 100644 --- a/.github/workflows/isort.yaml +++ b/.github/workflows/isort.yaml @@ -14,6 +14,6 @@ jobs: run: | # copy over the isort config file if [[ ! -f ".isort.cfg" ]]; then - wget https://raw.githubusercontent.com/mdolab/.github/main/.isort.cfg + wget https://raw.githubusercontent.com/mdolab/.github/main/pre-commit/_config/.isort.cfg fi - uses: isort/isort-action@master diff --git a/.github/workflows/pylint.yaml b/.github/workflows/pylint.yaml index 9fb2e63..2d6e6d6 100644 --- a/.github/workflows/pylint.yaml +++ b/.github/workflows/pylint.yaml @@ -17,5 +17,5 @@ jobs: - name: Run pylint run: | # copy over the pylint config file - wget https://raw.githubusercontent.com/mdolab/.github/main/.pylintrc + wget https://raw.githubusercontent.com/mdolab/.github/main/pre-commit/_config/.pylintrc find . -type f -name "*.py" -not -path "*/doc/*" | xargs pylint diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..a4e3a32 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,55 @@ +- id: mdolab-ruff-check + name: mdolab ruff (lint) + description: > + Ruff lint using mdolab's shared ruff.toml. A consuming repo's ./ruff.toml + (override keys only) is layered on top via ruff's `extend`. + entry: mdolab-ruff-check + language: python + additional_dependencies: ["ruff==0.15.16"] + types_or: [python, pyi] + require_serial: true + +- id: mdolab-ruff-format + name: mdolab ruff (format) + description: Ruff formatter using mdolab's shared ruff.toml (extendable locally). + entry: mdolab-ruff-format + language: python + additional_dependencies: ["ruff==0.15.16"] + types_or: [python, pyi] + require_serial: true + +- id: mdolab-isort + name: mdolab isort + description: isort using mdolab's shared isort.cfg. + entry: mdolab-isort + language: python + additional_dependencies: ["isort==8.0.1"] + types: [python] + require_serial: true + +- id: mdolab-pylint + name: mdolab pylint + description: pylint using mdolab's shared pylintrc. + entry: mdolab-pylint + language: python + additional_dependencies: ["pylint==4.0.6"] + types: [python] + require_serial: true + +- id: mdolab-clang-format + name: mdolab clang-format + description: clang-format using mdolab's shared style file. + entry: mdolab-clang-format + language: python + additional_dependencies: ["clang-format==10.0.1.1"] + types_or: [c, c++, cuda] + require_serial: true + +- id: mdolab-fprettify + name: mdolab fprettify + description: fprettify using mdolab's shared fprettify.rc. + entry: mdolab-fprettify + language: python + additional_dependencies: ["fprettify==0.3.7"] + files: '\.([fF]|[fF]9[05]|[fF]0[38])$' + require_serial: true diff --git a/azure/azure_style.yaml b/azure/azure_style.yaml index 210017f..238f6ad 100644 --- a/azure/azure_style.yaml +++ b/azure/azure_style.yaml @@ -43,7 +43,7 @@ jobs: # Set the ruff config in the .github repo as the global config, then the local config should extend it by including the line `extend = "~/.config/ruff/ruff.toml"` mkdir -p ~/.config/ruff - cp ../.github/ruff.toml ~/.config/ruff/ruff.toml + cp ../.github/pre-commit/_config/ruff.toml ~/.config/ruff/ruff.toml echo "Ruff config:" cat ~/.config/ruff/ruff.toml @@ -71,7 +71,7 @@ jobs: # copy over the isort config file if [[ ! -f ".isort.cfg" ]]; then - cp ../.github/.isort.cfg . + cp ../.github/pre-commit/_config/.isort.cfg . fi pip install wheel @@ -94,7 +94,7 @@ jobs: cd ${{ parameters.REPO_NAME }} # copy over the pylint config file - cp ../.github/.pylintrc . + cp ../.github/pre-commit/_config/.pylintrc . pip install pylint find . -type f -name "*.py" -not -path "*/doc/*" | xargs pylint diff --git a/azure/clang-format.sh b/azure/clang-format.sh index ac6ded6..1b868b1 100644 --- a/azure/clang-format.sh +++ b/azure/clang-format.sh @@ -24,7 +24,7 @@ done # Set some constants CLANGFORMAT_CONFIG_FILE="" LOCAL_CONFIG_FILE=".clang-format" -GLOBAL_CONFIG_FILE="../.github/.clang-format" +GLOBAL_CONFIG_FILE="../.github/pre-commit/_config/.clang-format" # Initialize variables global=0 diff --git a/azure/fprettify.sh b/azure/fprettify.sh index 91333e8..7fae4b6 100644 --- a/azure/fprettify.sh +++ b/azure/fprettify.sh @@ -2,7 +2,7 @@ FPRETTIFY_CONFIG_FILE="" LOCAL_CONFIG_FILE=".fprettify.rc" -GLOBAL_CONFIG_FILE="../.github/.fprettify.rc" +GLOBAL_CONFIG_FILE="../.github/pre-commit/_config/.fprettify.rc" if [[ ! -z "$1" ]]; then # Offer the option of supplying config file through an argument diff --git a/generate-pylintrc.sh b/generate-pylintrc.sh index e88a685..31d7221 100755 --- a/generate-pylintrc.sh +++ b/generate-pylintrc.sh @@ -5,4 +5,4 @@ pylint \ --enable basic,classes,exceptions,imports,newstyle,refactoring,stdlib,string,typecheck,variables \ --disable C,R,I,unspecified-encoding,protected-access,import-error \ --generate-rcfile \ -> .pylintrc +> pre-commit/_config/.pylintrc diff --git a/pre-commit/README.md b/pre-commit/README.md new file mode 100644 index 0000000..bd3ee5c --- /dev/null +++ b/pre-commit/README.md @@ -0,0 +1,56 @@ +# mdolab pre-commit hooks + +Repackaged pre-commit hooks that run pinned tools against mdolab's shared +configs, so every consuming repo lints and formats identically without +relying on globally installed tools or PATH ordering. + +## Using the hooks + +Add to a consuming repo's `.pre-commit-config.yaml`: + +```yaml +repos: + - repo: https://github.com/mdolab/.github + rev: # pin to a released tag or commit + hooks: + - id: mdolab-ruff-check + - id: mdolab-ruff-format + - id: mdolab-isort + - id: mdolab-pylint + - id: mdolab-clang-format + - id: mdolab-fprettify +``` + +Pick only the hooks you need. Then: + +```bash +pre-commit install +pre-commit run --all-files +``` + +Each hook installs its own pinned tool into an isolated environment, see [.pre-commit-hooks.yaml](../.pre-commit-hooks.yaml). + +## Local config overrides + +By default each hook uses the bundled shared config in [_config/](_config/). +A consuming repo can override by placing its own config at the repo root: + +| Hook | Local override file | Behavior | +| --------------------- | ------------------- | ------------------------------------------------ | +| `mdolab-ruff-check` | `./ruff.toml` | layered onto the shared base via ruff's `extend` | +| `mdolab-ruff-format` | `./ruff.toml` | same as above | +| `mdolab-isort` | `./.isort.cfg` | used instead of the shared config | +| `mdolab-pylint` | `./.pylintrc` | used instead of the shared config | +| `mdolab-clang-format` | `./.clang-format` | used instead of the shared config | +| `mdolab-fprettify` | `./.fprettify.rc` | used instead of the shared config | + +Ruff is special: a local `./ruff.toml` is *merged on top of* the shared base. +The other tools take the local file as-is. + +## Internals + +- [cli.py](cli.py) - the console-script entry points. Each resolves its bundled + config (`_config`, shipped as package data) and the pinned tool installed + beside `sys.executable` in pre-commit's isolated environment, then execs the + tool against the files pre-commit passes as argv. +- [_config/](_config/) - the shared configs, shipped as package data. diff --git a/.clang-format b/pre-commit/_config/.clang-format similarity index 100% rename from .clang-format rename to pre-commit/_config/.clang-format diff --git a/.fprettify.rc b/pre-commit/_config/.fprettify.rc similarity index 100% rename from .fprettify.rc rename to pre-commit/_config/.fprettify.rc diff --git a/.isort.cfg b/pre-commit/_config/.isort.cfg similarity index 100% rename from .isort.cfg rename to pre-commit/_config/.isort.cfg diff --git a/.pylintrc b/pre-commit/_config/.pylintrc similarity index 100% rename from .pylintrc rename to pre-commit/_config/.pylintrc diff --git a/pre-commit/_config/__init__.py b/pre-commit/_config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ruff.toml b/pre-commit/_config/ruff.toml similarity index 100% rename from ruff.toml rename to pre-commit/_config/ruff.toml diff --git a/pre-commit/cli.py b/pre-commit/cli.py new file mode 100644 index 0000000..53cada0 --- /dev/null +++ b/pre-commit/cli.py @@ -0,0 +1,156 @@ +"""Console-script entry points for mdolab's repackaged pre-commit hooks. + +Each entry point: + 1. locates its bundled config, shipped as package data (importlib.resources), + 2. locates the pinned tool installed in the SAME isolated environment that pre-commit built + 3. execs the tool against the files pre-commit passes as argv. + +Nothing here relies on a globally installed tool or on PATH ordering. +""" + +import contextlib +import os +import re +import shutil +import subprocess +import sys +import tempfile +from importlib.resources import as_file, files + + +@contextlib.contextmanager +def _config(name: str): + """Yield a real filesystem path to a bundled config file.""" + resource = files("_config").joinpath(name) + with as_file(resource) as path: + yield str(path) + + +def _tool(name: str) -> str: + """Absolute path to a pinned tool installed beside this interpreter. + + pre-commit's venv puts the interpreter and the tool console scripts in the + same bin/ directory, so resolving relative to sys.executable is exact and + PATH-independent. Falls back to PATH only as a courtesy. + """ + bindir = os.path.dirname(sys.executable) + for candidate in (os.path.join(bindir, name), os.path.join(bindir, name + ".exe")): + if os.path.isfile(candidate): + return candidate + found = shutil.which(name) + if found: + return found + sys.exit(f"mdolab-pre-commit: pinned tool '{name}' missing from the hook environment") + + +def _run(tool: str, args: list[str]) -> int: + return subprocess.call([_tool(tool), *args]) + + +def _argv_files() -> list[str]: + return sys.argv[1:] + + +def _run_with_local_config(tool: str, config_name: str, flag: str, *extra: str) -> None: + """Run `tool` over pre-commit's file list, preferring a repo-local config. + + The config is passed by path via `flag` (e.g. "-c" or "--settings-path"). + If the consuming repo ships its own `config_name` at the repo root + (pre-commit's cwd), that wins; otherwise the bundled shared config is used. + `extra` args are inserted after the config and before the file list. + """ + local = os.path.join(os.getcwd(), config_name) # pre-commit cwd == repo root + if os.path.isfile(local): + sys.exit(_run(tool, [flag, local, *extra, *_argv_files()])) + with _config(config_name) as cfg: + sys.exit(_run(tool, [flag, cfg, *extra, *_argv_files()])) + + +def _resolve_ruff_config(global_cfg: str) -> tuple[str, bool]: + """Return (config_path, is_temp). + + - No local ./ruff.toml -> use the shared base directly. + - Local ./ruff.toml present -> synthesize a temp config: + extend = '' + + so the shared base is inherited and the local file layers on top. Ruff + does not merge configs implicitly; `extend` is the supported mechanism, + and an absolute path sidesteps the unknown pre-commit cache location. + - Local file already declares its own top-level `extend` -> respect it. + """ + local = os.path.join(os.getcwd(), "ruff.toml") # pre-commit cwd == repo root + if not os.path.isfile(local): + return global_cfg, False + + with open(local, encoding="utf-8") as fh: + local_text = fh.read() + + if re.search(r"(?m)^\s*extend\s*=", local_text): + return local, False + + fd, tmp = tempfile.mkstemp(prefix="mdolab-ruff.", suffix=".toml") + with os.fdopen(fd, "w", encoding="utf-8") as out: + # single-quoted TOML literal string => no escaping, safe on Windows paths + out.write(f"extend = '{global_cfg}'\n") + out.write(local_text) + return tmp, True + + +def _ruff(subcommand: str, *extra: str) -> None: + with _config("ruff.toml") as global_cfg: + cfg, is_temp = _resolve_ruff_config(global_cfg) + try: + # --force-exclude: pre-commit always passes an explicit file list, + # and ruff checks explicitly-named files even when they match the + # config's exclude/extend-exclude unless this flag is set. Without + # it, a consuming repo's excludes are silently ignored on commit. + rc = _run("ruff", [subcommand, *extra, "--force-exclude", "--config", cfg, *_argv_files()]) + finally: + if is_temp: + with contextlib.suppress(FileNotFoundError): + os.unlink(cfg) + sys.exit(rc) + + +def ruff_check() -> None: + _ruff("check", "--fix", "--exit-non-zero-on-fix") + + +def ruff_format() -> None: + _ruff("format") + + +def isort() -> None: + # --filter-files: isort's skip / skip_glob only apply during directory + # recursion; pre-commit passes an explicit file list, so without this flag + # skipped files (e.g. __init__.py) would still be sorted. Mirrors ruff's + # --force-exclude. + _run_with_local_config("isort", ".isort.cfg", "--settings-path", "--filter-files") + + +def pylint() -> None: + _run_with_local_config("pylint", ".pylintrc", "--rcfile") + + +def clang_format() -> None: + # clang-format < 14 has no `--style=file:`; it only discovers a file + # literally named `.clang-format` by walking up from each input file: + # - prefer the consuming repo's own .clang-format when present, otherwise + # - stage the bundled shared config at the repo root for the run and remove it afterward. + # pre-commit's cwd is the repo root, an ancestor of every file it passes, so + # a .clang-format there is always discovered. + staged = os.path.join(os.getcwd(), ".clang-format") # pre-commit cwd == repo root + if os.path.isfile(staged): + sys.exit(_run("clang-format", ["-style=file", "-i", *_argv_files()])) + with _config(".clang-format") as cfg: + shutil.copyfile(cfg, staged) + try: + rc = _run("clang-format", ["-style=file", "-i", *_argv_files()]) + finally: + with contextlib.suppress(FileNotFoundError): + os.unlink(staged) + sys.exit(rc) + + +def fprettify() -> None: + _run_with_local_config("fprettify", ".fprettify.rc", "-c") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1eee462 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "mdolab-precommit-hooks" +version = "1.0.0" +description = "mdolab's repackaged pre-commit hooks with pinned tools and shared configs." +requires-python = ">=3.9" + +# Intentionally empty. Deps and pinned versions come from pre-commit. +dependencies = [] + +[project.scripts] +mdolab-ruff-check = "cli:ruff_check" +mdolab-ruff-format = "cli:ruff_format" +mdolab-isort = "cli:isort" +mdolab-pylint = "cli:pylint" +mdolab-clang-format = "cli:clang_format" +mdolab-fprettify = "cli:fprettify" + +# src-layout: importable code lives under pre-commit/, not the repo root. +# cli.py is a standalone top-level module (py-modules); _config is a tiny +# package that exists only to ship the bundled tool configs as package data. +[tool.setuptools] +package-dir = {"" = "pre-commit"} +py-modules = ["cli"] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["pre-commit"] + +# The shared configs are dotfiles, so the leading-dot glob is required -- +# a bare "*" does not match names beginning with ".". +[tool.setuptools.package-data] +_config = ["*", ".*"]