Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
42 changes: 42 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<Lib>-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). |
Expand Down Expand Up @@ -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.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,22 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
dependencies = [
"clang-format",
"conan>=2",
"devpi-client",
"jinja2",
"setuptools-scm",
"tabulate",
"toml",
"yapf",
]

[project.scripts]
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"
Expand Down
248 changes: 248 additions & 0 deletions tests/test_format.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions xmsconan/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions xmsconan/format_tools/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Source-formatting tools for xmsconan (clang-format for C++, yapf for Python)."""
Loading
Loading