From deb3f59a285ccd1770378b4c9f02501701268ff8 Mon Sep 17 00:00:00 2001 From: Bill Dolinar Date: Sat, 6 Jun 2026 22:38:33 -0600 Subject: [PATCH] Add xmsconan format command for C++ and Python Add an `xmsconan format` subcommand that formats C++ with clang-format and Python with yapf. It walks the given paths (default: cwd), skipping build/vcs/cache directories plus tests/fixtures and _package, and formats in place; --check reports diffs and exits non-zero without modifying files. Tool resolution is PATH-independent (bundled clang-format binary; `python -m yapf`). C++ uses the repo's own .clang-format; Python uses a fixed style (facebook base, 120 columns, --no-local-style). A mistyped input path fails loud (FormatToolError) rather than exiting 0. - Add clang-format and yapf to project dependencies and an xmsconan_format console script. - Register "format" in the cli.py COMMANDS dispatcher. - Document the command in README.md and docs/USAGE.md (new section 20). - Add tests/test_format.py (flake8 clean; 37 format tests, 558 total). --- README.md | 1 + docs/USAGE.md | 42 ++++ pyproject.toml | 3 + tests/test_format.py | 248 +++++++++++++++++++++ xmsconan/cli.py | 1 + xmsconan/format_tools/__init__.py | 1 + xmsconan/format_tools/format_code.py | 317 +++++++++++++++++++++++++++ 7 files changed, 613 insertions(+) create mode 100644 tests/test_format.py create mode 100644 xmsconan/format_tools/__init__.py create mode 100644 xmsconan/format_tools/format_code.py diff --git a/README.md b/README.md index 17ebd0b..086de04 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ xmsconan gen --help # help for a specific command | `xmsconan gen` | Generate build files from templates | | `xmsconan ci` | Generate CI pipeline files (GitLab/GitHub) from templates | | `xmsconan coverage` | Run unified C++/Python coverage (see `docs/USAGE.md` §11) | +| `xmsconan format` | Format C++ (clang-format) and Python (yapf) code (see `docs/USAGE.md` §20) | | `xmsconan build` | Build XMS libraries | | `xmsconan conan-setup` | Set up Conan profile and remotes for CI builds | | `xmsconan wheel-repair` | Repair Python wheels for the current platform (Linux/macOS/Windows) | diff --git a/docs/USAGE.md b/docs/USAGE.md index d0ae037..96d4375 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -67,6 +67,7 @@ All commands live under the `xmsconan` umbrella: |---|---| | `xmsconan gen` | Render build files (`conanfile.py`, `build.py`, `CMakeLists.txt`, `_package/pyproject.toml`, …) from `build.toml`. | | `xmsconan ci` | Render `.github/workflows/-CI.yaml` or `.gitlab-ci.yml`. | +| `xmsconan format` | Format C++ (clang-format) and Python (yapf) source in the current repo (see §20). | | `xmsconan build` | Run `conan install` + `cmake configure` against a single profile. Used by the generated `build.py`; also useful for one-off configures. | | `xmsconan conan-setup` | Detect a Conan profile, add the aquaveo remote, optionally login. | | `xmsconan wheel-repair` | Run platform-appropriate wheel repair (auditwheel / delocate / delvewheel). | @@ -567,3 +568,44 @@ Each suffix means: - `_DYNAMIC` (MSVC) — Dynamic CRT (`MD`/`MDd`) instead of static (`MT`/`MTd`) For a custom mix, write your own profile that `include()`s entries from `xmsconan/build_tools/profiles/base/` and pass it via `--profile /path/to/profile`. + +--- + +## 20. Formatting code (`xmsconan format`) + +`xmsconan format` (also available as the legacy `xmsconan_format` script) formats +a library's source in place: C++ with `clang-format` and Python with `yapf`. +Both tools ship as xmsconan dependencies, so on platforms that have prebuilt +`clang-format`/`yapf` wheels the command works immediately after +`pip install xmsconan` — no separate install needed. + +> **Version note.** The `clang-format` and `yapf` dependencies are unpinned +> (latest release), so formatter output can shift when a new version is +> published. Pin them in your environment if you need byte-stable formatting +> across machines and over time. + +```bash +xmsconan format # format the current repo (cwd) +xmsconan format src tools # format only these paths +xmsconan format --check # report files needing formatting, exit non-zero, change nothing +xmsconan format --cpp-only # C++ only (or --py-only for Python only) +xmsconan format --exclude vendor --exclude third_party +``` + +What it does: + +- **Discovery** — walks each given path (default `.`), collecting C++ + (`.cpp .cxx .cc .h .hpp .hxx`) and Python (`.py`) files. Build, VCS, and cache + directories (`build`, `dist`, `.git`, `.venv`, `__pycache__`, `*.egg-info`, + `.tox`, `node_modules`, `_package`, …) and `tests/fixtures` are skipped. Add + more with repeatable `--exclude DIR`. +- **C++ style** — uses the repository's own `.clang-format` (clang-format's + normal auto-discovery; LLVM defaults if none is present). +- **Python style** — a fixed style, `{based_on_style: facebook, column_limit: 120}`, + applied with `--no-local-style` so repo-local yapf config + (`.style.yapf` / `setup.cfg` / `[tool.yapf]`) is intentionally ignored. +- **`--check`** — runs `clang-format --dry-run -Werror` and `yapf --diff`, + prints what would change, and exits non-zero if anything needs formatting + without modifying files. Use it as a CI gate. + +Run `xmsconan format --help` for the full flag set. diff --git a/pyproject.toml b/pyproject.toml index 4153bf4..e52c8e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,12 +18,14 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ + "clang-format", "conan>=2", "devpi-client", "jinja2", "setuptools-scm", "tabulate", "toml", + "yapf", ] [project.scripts] @@ -31,6 +33,7 @@ xmsconan = "xmsconan.cli:main" xmsconan_gen = "xmsconan.generator_tools.build_file_generator:main" xmsconan_ci = "xmsconan.generator_tools.ci_file_generator:main" xmsconan_coverage = "xmsconan.coverage_tools.coverage_generator:main" +xmsconan_format = "xmsconan.format_tools.format_code:main" xmsconan_build = "xmsconan.build_tools.build_library:main" xmsconan_conan_setup = "xmsconan.ci_tools.conan_setup:main" xmsconan_wheel_repair = "xmsconan.ci_tools.wheel_repair:main" diff --git a/tests/test_format.py b/tests/test_format.py new file mode 100644 index 0000000..1004593 --- /dev/null +++ b/tests/test_format.py @@ -0,0 +1,248 @@ +"""Tests for the ``xmsconan format`` subcommand.""" +import argparse +import logging +import os +import subprocess +import sys +from unittest import mock + +import pytest + +from xmsconan.format_tools import format_code as fmt + +RUN = "xmsconan.format_tools.format_code.subprocess.run" +CLANG_BASE = "xmsconan.format_tools.format_code._clang_format_base" +YAPF_BASE = "xmsconan.format_tools.format_code._yapf_base" + + +def _ok(*_args, **_kwargs): + """subprocess.run replacement that reports a clean (exit 0) run.""" + return subprocess.CompletedProcess(args=[], returncode=0) + + +def _needs_formatting(*_args, **_kwargs): + """subprocess.run replacement that reports a needed change (exit non-zero).""" + return subprocess.CompletedProcess(args=[], returncode=1) + + +@pytest.fixture +def source_tree(tmp_path): + """Build a tree with C++, Python, and excluded directories; return its root.""" + (tmp_path / "src").mkdir() + (tmp_path / "src" / "a.cpp").write_text("int main(){}\n", encoding="utf-8") + (tmp_path / "src" / "a.h").write_text("int f();\n", encoding="utf-8") + (tmp_path / "mod.py").write_text("x=1\n", encoding="utf-8") + # Excluded: build output, virtualenv, and tests/fixtures. + (tmp_path / "build").mkdir() + (tmp_path / "build" / "gen.cpp").write_text("int g(){}\n", encoding="utf-8") + (tmp_path / ".venv").mkdir() + (tmp_path / ".venv" / "lib.py").write_text("y=2\n", encoding="utf-8") + (tmp_path / "tests" / "fixtures").mkdir(parents=True) + (tmp_path / "tests" / "fixtures" / "stub.cpp").write_text("int s(){}\n", encoding="utf-8") + # A "fixtures" dir NOT under tests/ is still formatted. + (tmp_path / "data" / "fixtures").mkdir(parents=True) + (tmp_path / "data" / "fixtures" / "keep.py").write_text("z=3\n", encoding="utf-8") + return tmp_path + + +def _commands(mock_run): + """Return the command list passed to each subprocess.run call.""" + return [call.args[0] for call in mock_run.call_args_list] + + +def _cmd_for(mock_run, tool): + """Return the single command whose executable ends with ``tool``.""" + return next(c for c in _commands(mock_run) if c[0].endswith(tool)) + + +class TestCollectFiles: + """File discovery picks up source and prunes excluded directories.""" + + def test_finds_source_and_skips_excluded_dirs(self, source_tree): + """build/, .venv/, and tests/fixtures/ are pruned; other dirs are kept.""" + cpp, py = fmt._collect_files( + [str(source_tree)], fmt.DEFAULT_EXCLUDE_DIRS, fmt.DEFAULT_REL_EXCLUDES + ) + names = {os.path.basename(p) for p in cpp + py} + assert names == {"a.cpp", "a.h", "mod.py", "keep.py"} + + def test_user_exclude_prunes_named_dir(self, source_tree): + """A directory passed via --exclude is removed from the results.""" + cpp, py = fmt._collect_files( + [str(source_tree)], + fmt.DEFAULT_EXCLUDE_DIRS | {"src"}, + fmt.DEFAULT_REL_EXCLUDES, + ) + names = {os.path.basename(p) for p in cpp + py} + assert "a.cpp" not in names + assert "mod.py" in names + + def test_subpath_root_still_prunes_tests_fixtures(self, source_tree): + """tests/fixtures is pruned even when the walk root is `tests`, not the repo root.""" + cpp, py = fmt._collect_files( + [str(source_tree / "tests")], fmt.DEFAULT_EXCLUDE_DIRS, fmt.DEFAULT_REL_EXCLUDES + ) + assert cpp == [] + assert py == [] + + def test_explicit_tests_fixtures_root_is_skipped(self, source_tree): + """Handing tests/fixtures in directly as a path is skipped, not formatted.""" + cpp, py = fmt._collect_files( + [str(source_tree / "tests" / "fixtures")], + fmt.DEFAULT_EXCLUDE_DIRS, + fmt.DEFAULT_REL_EXCLUDES, + ) + assert cpp == [] + assert py == [] + + +class TestApplyMode: + """Apply mode invokes both formatters in place.""" + + @mock.patch(RUN, side_effect=_ok) + @mock.patch(YAPF_BASE, return_value=["yapf"]) + @mock.patch(CLANG_BASE, return_value=["clang-format"]) + def test_invokes_clang_format_and_yapf_in_place(self, _clang, _yapf, mock_run, source_tree): + """clang-format -i and yapf -i (with the fixed style) are both run.""" + assert fmt.format_code([str(source_tree)]) == 0 + + cpp_cmd = _cmd_for(mock_run, "clang-format") + py_cmd = _cmd_for(mock_run, "yapf") + assert "-i" in cpp_cmd + assert "-i" in py_cmd + assert f"--style={fmt.YAPF_STYLE}" in py_cmd + assert "--no-local-style" in py_cmd + + @mock.patch(RUN, side_effect=_ok) + @mock.patch(YAPF_BASE, return_value=["yapf"]) + @mock.patch(CLANG_BASE, return_value=["clang-format"]) + def test_cpp_only_skips_python(self, _clang, mock_yapf, mock_run, source_tree): + """--cpp-only runs clang-format and never resolves or invokes yapf.""" + fmt.format_code([str(source_tree)], cpp_only=True) + tools = [c[0] for c in _commands(mock_run)] + assert any(t.endswith("clang-format") for t in tools) + assert not any(t.endswith("yapf") for t in tools) + mock_yapf.assert_not_called() + + @mock.patch(RUN, side_effect=_ok) + @mock.patch(YAPF_BASE, return_value=["yapf"]) + @mock.patch(CLANG_BASE, return_value=["clang-format"]) + def test_py_only_skips_cpp(self, mock_clang, _yapf, mock_run, source_tree): + """--py-only runs yapf and never resolves or invokes clang-format.""" + fmt.format_code([str(source_tree)], py_only=True) + tools = [c[0] for c in _commands(mock_run)] + assert any(t.endswith("yapf") for t in tools) + assert not any(t.endswith("clang-format") for t in tools) + mock_clang.assert_not_called() + + @mock.patch(RUN, side_effect=_needs_formatting) + @mock.patch(YAPF_BASE, return_value=["yapf"]) + @mock.patch(CLANG_BASE, return_value=["clang-format"]) + def test_formatter_error_returns_one(self, _clang, _yapf, _run, source_tree): + """In apply mode, a formatter exiting non-zero makes format_code return 1.""" + assert fmt.format_code([str(source_tree)]) == 1 + + +class TestCheckMode: + """--check verifies without modifying and reflects the diff in the exit code.""" + + @mock.patch(RUN, side_effect=_needs_formatting) + @mock.patch(YAPF_BASE, return_value=["yapf"]) + @mock.patch(CLANG_BASE, return_value=["clang-format"]) + def test_uses_dry_run_flags_and_returns_nonzero(self, _clang, _yapf, mock_run, source_tree): + """Check mode uses --dry-run/--diff (no -i) and returns 1 on a diff.""" + assert fmt.format_code([str(source_tree)], check=True) == 1 + + cpp_cmd = _cmd_for(mock_run, "clang-format") + py_cmd = _cmd_for(mock_run, "yapf") + assert "--dry-run" in cpp_cmd and "-Werror" in cpp_cmd + assert "--diff" in py_cmd + assert "-i" not in cpp_cmd + assert "-i" not in py_cmd + + @mock.patch(RUN, side_effect=_ok) + @mock.patch(YAPF_BASE, return_value=["yapf"]) + @mock.patch(CLANG_BASE, return_value=["clang-format"]) + def test_clean_tree_returns_zero(self, _clang, _yapf, _run, source_tree): + """Check mode returns 0 when no file needs formatting.""" + assert fmt.format_code([str(source_tree)], check=True) == 0 + + +class TestToolResolution: + """Formatter binaries resolve without depending on PATH.""" + + def test_yapf_base_uses_current_interpreter(self): + """Resolve yapf to `python -m yapf` in the running interpreter.""" + assert fmt._yapf_base() == [sys.executable, "-m", "yapf"] + + def test_clang_format_base_resolves_existing_binary(self): + """Resolve clang-format to an existing executable named clang-format.""" + base = fmt._clang_format_base() + assert len(base) == 1 + assert os.path.exists(base[0]) + assert os.path.basename(base[0]).startswith("clang-format") + + @mock.patch("xmsconan.format_tools.format_code.shutil.which", return_value=None) + @mock.patch("xmsconan.format_tools.format_code._clang_format_get_executable", None) + def test_clang_format_missing_raises(self, _which): + """A missing clang-format (no wheel, not on PATH) raises FormatToolError.""" + with pytest.raises(fmt.FormatToolError, match="clang-format"): + fmt._clang_format_base() + + @mock.patch("xmsconan.format_tools.format_code.importlib.util.find_spec", return_value=None) + def test_yapf_missing_raises(self, _find_spec): + """A missing yapf raises FormatToolError.""" + with pytest.raises(fmt.FormatToolError, match="yapf"): + fmt._yapf_base() + + +class TestEmptyTree: + """A tree with no source files is a clean no-op.""" + + def test_returns_zero_without_running_formatters(self, tmp_path): + """No files found means exit 0 and no formatter resolution.""" + assert fmt.format_code([str(tmp_path)]) == 0 + + +class TestInputValidation: + """Bad input fails loud rather than silently succeeding.""" + + def test_nonexistent_path_raises(self, tmp_path): + """An explicit path that does not exist raises FormatToolError (not exit 0).""" + missing = str(tmp_path / "does_not_exist") + with pytest.raises(fmt.FormatToolError, match="does not exist"): + fmt.format_code([missing]) + + def test_cpp_only_and_py_only_raises(self, source_tree): + """Requesting both cpp_only and py_only is a contradiction, not a no-op.""" + with pytest.raises(ValueError, match="mutually exclusive"): + fmt.format_code([str(source_tree)], cpp_only=True, py_only=True) + + +class TestLogLevel: + """Verbosity flags map to the expected logging level.""" + + @pytest.mark.parametrize("quiet,verbose,expected", [ + (False, 0, logging.INFO), + (False, 1, logging.DEBUG), + (True, 0, logging.ERROR), + (True, 1, logging.ERROR), # --quiet wins over -v + ]) + def test_log_level(self, quiet, verbose, expected): + """_log_level picks ERROR/DEBUG/INFO from the quiet/verbose flags.""" + args = argparse.Namespace(quiet=quiet, verbose=verbose) + assert fmt._log_level(args) == expected + + +class TestMain: + """The CLI entry point maps the worker exit code onto SystemExit.""" + + @mock.patch(RUN, side_effect=_needs_formatting) + @mock.patch(YAPF_BASE, return_value=["yapf"]) + @mock.patch(CLANG_BASE, return_value=["clang-format"]) + def test_check_propagates_nonzero_exit(self, _clang, _yapf, _run, source_tree, monkeypatch): + """`xmsconan format --check` exits non-zero when files need formatting.""" + monkeypatch.setattr(sys, "argv", ["xmsconan format", "--check", str(source_tree)]) + with pytest.raises(SystemExit) as exc: + fmt.main() + assert exc.value.code == 1 diff --git a/xmsconan/cli.py b/xmsconan/cli.py index d3e8246..7426fac 100644 --- a/xmsconan/cli.py +++ b/xmsconan/cli.py @@ -13,6 +13,7 @@ "gen": ("Generate build files from templates", "xmsconan.generator_tools.build_file_generator", "main"), "ci": ("Generate CI pipeline files from templates", "xmsconan.generator_tools.ci_file_generator", "main"), "coverage": ("Run unified C++/Python coverage", "xmsconan.coverage_tools.coverage_generator", "main"), + "format": ("Format C++ (clang-format) and Python (yapf) code", "xmsconan.format_tools.format_code", "main"), "build": ("Build XMS libraries", "xmsconan.build_tools.build_library", "main"), "conan-setup": ("Set up Conan profile and remotes", "xmsconan.ci_tools.conan_setup", "main"), "wheel-repair": ("Repair Python wheels for the current platform", "xmsconan.ci_tools.wheel_repair", "main"), diff --git a/xmsconan/format_tools/__init__.py b/xmsconan/format_tools/__init__.py new file mode 100644 index 0000000..42d50ef --- /dev/null +++ b/xmsconan/format_tools/__init__.py @@ -0,0 +1 @@ +"""Source-formatting tools for xmsconan (clang-format for C++, yapf for Python).""" diff --git a/xmsconan/format_tools/format_code.py b/xmsconan/format_tools/format_code.py new file mode 100644 index 0000000..acb6486 --- /dev/null +++ b/xmsconan/format_tools/format_code.py @@ -0,0 +1,317 @@ +"""Format C++ (clang-format) and Python (yapf) source in a repository. + +Usage:: + + xmsconan format [PATHS ...] [--check] [--cpp-only | --py-only] + [--exclude DIR ...] [-v] [-q] + +Walks each path (default: the current directory), collects C++ and Python +source files, and formats them in place. C++ uses the repository's own +``.clang-format`` (clang-format's normal auto-discovery); Python uses a fixed +yapf style (facebook base, 120-column) and intentionally ignores any +repo-local yapf config via ``--no-local-style``. +""" +import argparse +import fnmatch +import importlib.util +import logging +import os +import shutil +import subprocess +import sys + +try: + from clang_format import get_executable as _clang_format_get_executable +except ImportError: # pragma: no cover - only hit when the clang-format wheel is absent + _clang_format_get_executable = None + +logger = logging.getLogger(__name__) + +# Fixed yapf style — the canonical Python style is defined here, not discovered +# from the repo (``--no-local-style`` ignores .style.yapf / setup.cfg / pyproject). +YAPF_STYLE = "{based_on_style: facebook, column_limit: 120}" + +# Extensions are matched case-insensitively; keep them as tuples for str.endswith. +CPP_EXTENSIONS = (".cpp", ".cxx", ".cc", ".h", ".hpp", ".hxx") +PY_EXTENSIONS = (".py",) + +# Directory basenames pruned from the walk: universal build/vcs/cache dirs plus +# ``_package`` (generated). Mirrors the spirit of the repo's .flake8 excludes. +DEFAULT_EXCLUDE_DIRS = frozenset({ + ".git", ".hg", ".svn", + ".venv", "venv", "env", + "__pycache__", ".eggs", ".tox", ".cache", ".mypy_cache", ".pytest_cache", + "build", "dist", "node_modules", + "_package", +}) + +# Directory basename glob patterns pruned from the walk (matched at any depth). +EXCLUDE_GLOBS = frozenset({"*.egg-info"}) + +# Directory path *suffixes* ("/"-separated components) pruned from the walk at any +# depth, and skipped if handed in directly as a path. Excluding ``tests/fixtures`` +# keeps the command from reformatting test fixtures (e.g. xmsconan's own C++ stubs +# under tests/fixtures/coverage_stub, whose exact content the coverage tests assert) +# while still formatting real "fixtures" dirs that are not under ``tests/``. +DEFAULT_REL_EXCLUDES = frozenset({"tests/fixtures"}) + +# Cap files per subprocess invocation to stay under the OS argument-length limit. +_BATCH_SIZE = 200 + + +class FormatToolError(RuntimeError): + """A required formatter binary is missing or could not be run.""" + + +def _log_level(args): + """Return the logging level for the given CLI verbosity flags.""" + if args.quiet: + return logging.ERROR + if args.verbose > 0: + return logging.DEBUG + return logging.INFO + + +def _configure_logging(args): + """Configure the logger from CLI verbosity flags.""" + logging.basicConfig(level=_log_level(args), format="%(levelname)s: %(message)s") + + +def _chunked(seq, size): + """Yield successive ``size``-length chunks of ``seq``.""" + for start in range(0, len(seq), size): + yield seq[start:start + size] + + +def _classify(path, cpp, py): + """Add ``path`` to the ``cpp`` or ``py`` set based on its extension.""" + lowered = path.lower() + if lowered.endswith(CPP_EXTENSIONS): + cpp.add(path) + elif lowered.endswith(PY_EXTENSIONS): + py.add(path) + + +def _matches_rel_exclude(path, rel_excludes): + """Return True if ``path`` ends with any rel-exclude's component sequence. + + Matching is by trailing path components (not a string suffix), so it fires at + any depth and regardless of the walk root, but ``mytests/fixtures`` does not + match ``tests/fixtures``. + """ + parts = os.path.normpath(path).replace(os.sep, "/").split("/") + for rel in rel_excludes: + rel_parts = rel.split("/") + if len(parts) >= len(rel_parts) and parts[-len(rel_parts):] == rel_parts: + return True + return False + + +def _is_excluded(name, dirpath, exclude_dirs, rel_excludes): + """Return True if directory ``name`` under ``dirpath`` should be pruned.""" + if name in exclude_dirs or any(fnmatch.fnmatch(name, g) for g in EXCLUDE_GLOBS): + return True + return _matches_rel_exclude(os.path.join(dirpath, name), rel_excludes) + + +def _collect_files(roots, exclude_dirs, rel_excludes): + """Return ``(cpp_files, py_files)`` found under ``roots``, skipping excludes. + + Args: + roots: Iterable of file or directory paths to search. + exclude_dirs: Directory basenames to prune from the walk. + rel_excludes: Directory path suffixes ("/"-separated components) to prune + at any depth (also skipped when handed in directly as a root). + + Returns: + A tuple of two sorted, de-duplicated lists: C++ files and Python files. + + Raises: + FormatToolError: if an explicitly given ``root`` does not exist. Failing + loud keeps a mistyped path from silently "succeeding" with exit 0. + """ + cpp, py = set(), set() + for root in roots: + if os.path.isfile(root): + _classify(root, cpp, py) + continue + if not os.path.exists(root): + raise FormatToolError(f"path does not exist: {root}") + if _matches_rel_exclude(root, rel_excludes): + logger.info("Skipping excluded path: %s", root) + continue + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [ + d for d in dirnames + if not _is_excluded(d, dirpath, exclude_dirs, rel_excludes) + ] + for name in filenames: + _classify(os.path.join(dirpath, name), cpp, py) + return sorted(cpp), sorted(py) + + +def _clang_format_base(): + """Return the base argv for clang-format, preferring the bundled wheel binary. + + The ``clang-format`` pip wheel installs a binary under its package data and + exposes it via ``get_executable``. Using that path is PATH-independent and + pins formatting to the version that shipped with xmsconan rather than any + system clang-format that happens to be on PATH. + """ + if _clang_format_get_executable is not None: + executable = _clang_format_get_executable("clang-format") + else: + executable = shutil.which("clang-format") + if not executable or not os.path.exists(executable): + raise FormatToolError( + "clang-format was not found. It ships as an xmsconan dependency; " + "reinstall it with `pip install clang-format`." + ) + return [executable] + + +def _yapf_base(): + """Return the base argv for yapf, run via the current interpreter. + + Invoking ``python -m yapf`` uses the yapf installed in the same environment + as xmsconan without depending on the bin/Scripts directory being on PATH. + """ + if importlib.util.find_spec("yapf") is None: + raise FormatToolError( + "yapf was not found. It ships as an xmsconan dependency; " + "reinstall it with `pip install yapf`." + ) + return [sys.executable, "-m", "yapf"] + + +def _run_in_batches(base_cmd, files): + """Run ``base_cmd`` over ``files`` in batches; return the failing-batch count. + + A non-zero return code counts as a failure. In ``--check`` mode that means + "files need formatting"; in apply mode it means the formatter errored. + """ + failures = 0 + for batch in _chunked(files, _BATCH_SIZE): + # stderr is intentionally inherited (not captured) so the formatter's + # own per-file diagnostics reach the user. + try: + result = subprocess.run(base_cmd + batch) + except OSError as exc: + raise FormatToolError( + f"failed to run formatter ({' '.join(base_cmd)}): {exc}" + ) from exc + if result.returncode != 0: + failures += 1 + return failures + + +def _format_cpp(files, check): + """Run clang-format over ``files``; return the failing-batch count.""" + base = _clang_format_base() + flags = ["--dry-run", "-Werror"] if check else ["-i"] + logger.info( + "%s %d C++ file(s) with clang-format...", + "Checking" if check else "Formatting", len(files), + ) + return _run_in_batches(base + flags, files) + + +def _format_python(files, check): + """Run yapf over ``files`` with the fixed style; return failing-batch count.""" + base = _yapf_base() + style = [f"--style={YAPF_STYLE}", "--no-local-style"] + flags = ["--diff"] if check else ["-i"] + logger.info( + "%s %d Python file(s) with yapf...", + "Checking" if check else "Formatting", len(files), + ) + return _run_in_batches(base + flags + style, files) + + +def format_code(paths, check=False, cpp_only=False, py_only=False, exclude=()): + """Format (or check) C++ and Python source under ``paths``. + + Args: + paths: Files or directories to process. + check: If True, report files needing formatting without modifying them. + cpp_only: If True, only process C++ files. + py_only: If True, only process Python files. + exclude: Extra directory basenames to prune from the walk. + + Returns: + ``0`` if everything is clean/formatted, ``1`` otherwise. + + Raises: + ValueError: if both ``cpp_only`` and ``py_only`` are set (the CLI makes + them mutually exclusive; this guards direct callers). + """ + if cpp_only and py_only: + raise ValueError("cpp_only and py_only are mutually exclusive") + exclude_dirs = DEFAULT_EXCLUDE_DIRS | set(exclude) + cpp_files, py_files = _collect_files(paths, exclude_dirs, DEFAULT_REL_EXCLUDES) + if py_only: + cpp_files = [] + if cpp_only: + py_files = [] + + if not cpp_files and not py_files: + logger.info("No C++ or Python files found to format.") + return 0 + + failures = 0 + if cpp_files: + failures += _format_cpp(cpp_files, check) + if py_files: + failures += _format_python(py_files, check) + + if failures: + if check: + logger.error("Some files need formatting. Run `xmsconan format` to fix them.") + else: + logger.error("One or more formatters reported errors.") + return 1 + logger.info("All files are correctly formatted." if check else "Formatting complete.") + return 0 + + +def main(): + """CLI entry point for ``xmsconan format`` (and ``xmsconan_format``).""" + parser = argparse.ArgumentParser( + description="Format C++ (clang-format) and Python (yapf) source in place.", + ) + parser.add_argument( + "paths", nargs="*", default=["."], + help="Files or directories to format (default: current directory).", + ) + parser.add_argument( + "--check", action="store_true", + help="Report files that need formatting and exit non-zero without modifying them.", + ) + group = parser.add_mutually_exclusive_group() + group.add_argument("--cpp-only", action="store_true", help="Only format C++ files.") + group.add_argument("--py-only", action="store_true", help="Only format Python files.") + parser.add_argument( + "--exclude", action="append", default=[], metavar="DIR", + help="Directory basename to skip, matched at any depth " + "(repeatable; added to the built-in excludes).", + ) + parser.add_argument( + "-v", "--verbose", action="count", default=0, + help="Increase output verbosity (use -v for debug details).", + ) + parser.add_argument("-q", "--quiet", action="store_true", help="Only show errors.") + args = parser.parse_args() + _configure_logging(args) + + try: + exit_code = format_code( + paths=args.paths, + check=args.check, + cpp_only=args.cpp_only, + py_only=args.py_only, + exclude=args.exclude, + ) + except FormatToolError as exc: + print(f"Error: {exc}", file=sys.stderr) + raise SystemExit(1) from exc + raise SystemExit(exit_code)