From b4f6e1253356370c8d8855ea0b155e844db98b83 Mon Sep 17 00:00:00 2001 From: Egor Tensin Date: Mon, 5 Oct 2026 10:34:55 +0500 Subject: tools/project-clang-format.py -> project/format.py --- .github/workflows/lint.yml | 6 +- project/format.py | 180 ++++++++++++++++++++++++++++++++ project/utils.py | 30 +++++- pyproject.toml | 2 +- tools/project-clang-format.py | 236 ------------------------------------------ 5 files changed, 211 insertions(+), 243 deletions(-) create mode 100644 project/format.py delete mode 100755 tools/project-clang-format.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index afefd20..5566327 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: jobs: - # Check that project-clang-format.py works (and the C/C++ files are formatted). + # Check that project.format works (and the C/C++ files are formatted). lint: runs-on: ubuntu-latest name: Linting @@ -13,11 +13,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v7 - - name: Run clang-format - run: ./tools/project-clang-format.py - name: Set up Python uses: actions/setup-python@v7 with: python-version: '3.x' + - name: Run clang-format + run: python -m project.format - name: Run black uses: psf/black@stable diff --git a/project/format.py b/project/format.py new file mode 100644 index 0000000..5f24062 --- /dev/null +++ b/project/format.py @@ -0,0 +1,180 @@ +# Copyright (c) 2020 Egor Tensin +# This file is part of the "cmake-common" project. +# For details, see https://github.com/egor-tensin/cmake-common +# Distributed under the MIT License. + +"""clang-format all C/C++ files in the project + +This script feeds every C/C++ file in the repository to clang-format, either +printing a unified diff between the original and the formatted versions, or +formatting the files in-place. +""" + +import argparse +import difflib +import logging +import os +import sys + +from project.utils import cd, normalize_path, run_capture, setup_logging + + +def read_file(path): + with open(path) as file: + return file.read() + + +class ClangFormat: + def __init__(self, path, style): + self.path = path + self.style = style + + def _get_command_line(self, paths, in_place=False): + cmd_line = [self.path, f"-style={self.style}"] + if in_place: + cmd_line.append("-i") + cmd_line.append("--") + cmd_line += list(paths) + return cmd_line + + def format_in_place(self, paths): + run_capture(self._get_command_line(paths, in_place=True)) + + @staticmethod + def _show_diff(path, formatted): + original = read_file(path) + if original and not formatted: + # Assuming this file is ignored in .clang-format-ignore. + return True + + original = original.split("\n") + formatted = formatted.split("\n") + original_lbl = f"{path} (original)" + formatted_lbl = f"{path} (formatted)" + + diff = difflib.unified_diff( + original, + formatted, + fromfile=original_lbl, + tofile=formatted_lbl, + lineterm="", + ) + + clean = True + for line in diff: + clean = False + print(line) + return clean + + def show_diff(self, paths): + clean = True + for path in paths: + formatted = run_capture(self._get_command_line([path])) + clean = self._show_diff(path, formatted) and clean + return clean + + +def git_root_dir(): + cmd_line = ["git", "rev-parse", "--show-toplevel"] + root_dir = run_capture(cmd_line) + if root_dir[-1] != "\n": + raise RuntimeError("git rev-parse --show-toplevel should append a newline?") + return root_dir[:-1] + + +def list_git_files(): + cmd_line = ["git", "ls-tree", "-r", "-z", "--name-only", "HEAD"] + repo_files = run_capture(cmd_line) + repo_files = repo_files.split("\0") + return repo_files + + +def list_all_files(): + return (normalize_path(path) for path in list_git_files()) + + +def excluded(path, exclude): + for entry in exclude: + if entry == os.path.commonpath((path, entry)): + return True + return False + + +def filter_files(paths, exclude): + if not exclude: + return paths + return (path for path in paths if not excluded(path, exclude)) + + +CPP_FILE_EXTENSIONS = set( + (".c", ".h", ".cc", ".hh", ".cpp", ".hpp", ".cxx", ".hxx", ".cp", ".c++") +) + + +def list_cpp_files(): + for path in list_all_files(): + ext = os.path.splitext(path)[1].lower() + if ext in CPP_FILE_EXTENSIONS: + yield path + + +DEFAULT_VERSION = "clang-format" +DEFAULT_STYLE = "file" + + +def process_cpp_files( + version=DEFAULT_VERSION, style=DEFAULT_STYLE, in_place=False, exclude=None +): + clang_format = ClangFormat(version, style) + with cd(git_root_dir()): + cpp_files = filter_files(list_cpp_files(), exclude) + if in_place: + clang_format.format_in_place(cpp_files) + else: + if not clang_format.show_diff(cpp_files): + sys.exit(1) + + +def parse_args(argv=None): + if argv is None: + argv = sys.argv[1:] + + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + + parser.add_argument( + "-b", + "--clang-format", + dest="version", + default=DEFAULT_VERSION, + help="clang-format executable file path", + ) + parser.add_argument( + "-s", + "--style", + default=DEFAULT_STYLE, + help="clang-format -style parameter argument", + ) + parser.add_argument( + "-i", "--in-place", action="store_true", help="edit the files in-place" + ) + parser.add_argument( + "-e", + "--exclude", + nargs="*", + type=normalize_path, + help="files or directories to exclude", + ) + + return parser.parse_args(argv) + + +def main(argv=None): + args = parse_args(argv) + with setup_logging(): + process_cpp_files(**vars(args)) + + +if __name__ == "__main__": + main() diff --git a/project/utils.py b/project/utils.py index 8ef6639..3fb73c4 100644 --- a/project/utils.py +++ b/project/utils.py @@ -7,6 +7,7 @@ from contextlib import contextmanager import functools import logging import os.path +import shlex import shutil import subprocess import sys @@ -81,9 +82,32 @@ def cd(path): os.chdir(cwd) -def run(cmd_line, **kwargs): - logging.info("Running: %s", subprocess.list2cmdline(cmd_line)) - return subprocess.run(cmd_line, check=True, **kwargs) +def run(cmd, **kwargs): + logging.info("Running: %s", shlex.join(cmd)) + return subprocess.run(cmd, check=True, **kwargs) + + +def _run_log_output(level, output): + if not output: + logging.log(level, "... No output") + logging.log(level, "Output (%d) characters):", len(output)) + for line in output.splitlines(): + logging.log(level, " %s", line) + + +def run_capture(cmd, **kwargs): + stdout = subprocess.PIPE + stderr = subprocess.STDOUT + + try: + result = run(cmd, **kwargs, stdout=stdout, stderr=stderr, encoding="utf-8") + except subprocess.CalledProcessError as e: + logging.error("... Exited with code %d", e.returncode) + _run_log_output(logging.ERROR, e.output) + raise + + _run_log_output(logging.DEBUG, result.stdout) + return result.stdout @contextmanager diff --git a/pyproject.toml b/pyproject.toml index beb76b9..7fd21f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,11 +30,11 @@ classifiers = [ boost-build = "project.boost.build:_main" boost-download = "project.boost.download:_main" project-build = "project.build:main" +project-format = "project.format:main" [tool.setuptools] script-files = [ "tools/ctest-driver.py", - "tools/project-clang-format.py", ] [tool.setuptools_scm] diff --git a/tools/project-clang-format.py b/tools/project-clang-format.py deleted file mode 100755 index 4b066c3..0000000 --- a/tools/project-clang-format.py +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env python - -# Copyright (c) 2020 Egor Tensin -# This file is part of the "cmake-common" project. -# For details, see https://github.com/egor-tensin/cmake-common -# Distributed under the MIT License. - -"""clang-format all C/C++ files in the project - -This script feeds every C/C++ file in the repository to clang-format, either -printing a unified diff between the original and the formatted versions, or -formatting the files in-place. -""" - -import argparse -from contextlib import contextmanager -import difflib -import logging -import os -import subprocess -import sys - - -@contextmanager -def setup_logging(): - level_names = { - logging.DEBUG: "DBG", - logging.INFO: "INFO", - logging.WARNING: "WARN", - logging.ERROR: "ERR", - logging.CRITICAL: "CRIT", - } - for lvl, name in level_names.items(): - logging.addLevelName(lvl, name) - - logging.basicConfig( - level=logging.INFO, - datefmt="%Y-%m-%d %H:%M:%S%z", - format="%(asctime)s | %(levelname)4s | %(message)s", - ) - try: - yield - except Exception as e: - logging.exception(e) - sys.exit(1) - - -@contextmanager -def cd(path): - cwd = os.getcwd() - os.chdir(path) - try: - yield - finally: - os.chdir(cwd) - - -def normalize_path(entry): - return os.path.abspath(entry) - - -def run(cmd_line): - logging.debug("Running executable: %s", cmd_line) - try: - return subprocess.run( - cmd_line, - check=True, - universal_newlines=True, - stderr=subprocess.STDOUT, - stdout=subprocess.PIPE, - ) - except subprocess.CalledProcessError as e: - logging.error("Process finished with exit code %d: %s", e.returncode, cmd_line) - logging.error("Its output was:\n%s", e.output) - raise - - -def read_file(path): - with open(path) as file: - return file.read() - - -class ClangFormat: - def __init__(self, path, style): - self.path = path - self.style = style - - def _get_command_line(self, paths, in_place=False): - cmd_line = [self.path, f"-style={self.style}"] - if in_place: - cmd_line.append("-i") - cmd_line.append("--") - cmd_line += list(paths) - return cmd_line - - def format_in_place(self, paths): - run(self._get_command_line(paths, in_place=True)) - - @staticmethod - def _show_diff(path, formatted): - original = read_file(path) - if original and not formatted: - # Assuming this file is ignored in .clang-format-ignore. - return True - - original = original.split("\n") - formatted = formatted.split("\n") - original_lbl = f"{path} (original)" - formatted_lbl = f"{path} (formatted)" - - diff = difflib.unified_diff( - original, - formatted, - fromfile=original_lbl, - tofile=formatted_lbl, - lineterm="", - ) - - clean = True - for line in diff: - clean = False - print(line) - return clean - - def show_diff(self, paths): - clean = True - for path in paths: - formatted = run(self._get_command_line([path])).stdout - clean = self._show_diff(path, formatted) and clean - return clean - - -def git_root_dir(): - cmd_line = ["git", "rev-parse", "--show-toplevel"] - root_dir = run(cmd_line).stdout - if root_dir[-1] != "\n": - raise RuntimeError("git rev-parse --show-toplevel should append a newline?") - return root_dir[:-1] - - -def list_git_files(): - cmd_line = ["git", "ls-tree", "-r", "-z", "--name-only", "HEAD"] - repo_files = run(cmd_line).stdout - repo_files = repo_files.split("\0") - return repo_files - - -def list_all_files(): - return (normalize_path(path) for path in list_git_files()) - - -def excluded(path, exclude): - for entry in exclude: - if entry == os.path.commonpath((path, entry)): - return True - return False - - -def filter_files(paths, exclude): - if not exclude: - return paths - return (path for path in paths if not excluded(path, exclude)) - - -CPP_FILE_EXTENSIONS = set( - (".c", ".h", ".cc", ".hh", ".cpp", ".hpp", ".cxx", ".hxx", ".cp", ".c++") -) - - -def list_cpp_files(): - for path in list_all_files(): - ext = os.path.splitext(path)[1].lower() - if ext in CPP_FILE_EXTENSIONS: - yield path - - -DEFAULT_VERSION = "clang-format" -DEFAULT_STYLE = "file" - - -def process_cpp_files( - version=DEFAULT_VERSION, style=DEFAULT_STYLE, in_place=False, exclude=None -): - clang_format = ClangFormat(version, style) - with cd(git_root_dir()): - cpp_files = filter_files(list_cpp_files(), exclude) - if in_place: - clang_format.format_in_place(cpp_files) - else: - if not clang_format.show_diff(cpp_files): - sys.exit(1) - - -def parse_args(argv=None): - if argv is None: - argv = sys.argv[1:] - - parser = argparse.ArgumentParser( - description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter - ) - - parser.add_argument( - "-b", - "--clang-format", - dest="version", - default=DEFAULT_VERSION, - help="clang-format executable file path", - ) - parser.add_argument( - "-s", - "--style", - default=DEFAULT_STYLE, - help="clang-format -style parameter argument", - ) - parser.add_argument( - "-i", "--in-place", action="store_true", help="edit the files in-place" - ) - parser.add_argument( - "-e", - "--exclude", - nargs="*", - type=normalize_path, - help="files or directories to exclude", - ) - - return parser.parse_args(argv) - - -def main(argv=None): - args = parse_args(argv) - with setup_logging(): - process_cpp_files(**vars(args)) - - -if __name__ == "__main__": - main() -- cgit v1.3