Files

267 lines
9.7 KiB
Python

"""Tests for esphome.build_helpers.ninja."""
from __future__ import annotations
import os
from pathlib import Path
import subprocess
import sys
from unittest.mock import MagicMock, patch
import pytest
from esphome.build_helpers import ninja as ninja_helper
from esphome.core import EsphomeError
def test_find_ninja_prefers_path(tmp_path: Path) -> None:
with (
patch("shutil.which", return_value=str(tmp_path / "ninja")),
patch.object(ninja_helper, "_ninja_runs", return_value=True),
):
assert ninja_helper.find_ninja() == tmp_path / "ninja"
def test_find_ninja_falls_back_to_wheel(tmp_path: Path) -> None:
"""Without a PATH entry, the ninja PyPI wheel's binary is used."""
binary_name = "ninja.exe" if os.name == "nt" else "ninja"
(tmp_path / binary_name).touch()
wheel = MagicMock(BIN_DIR=str(tmp_path))
with (
patch("shutil.which", return_value=None),
patch.dict(sys.modules, {"ninja": wheel}),
):
assert ninja_helper.find_ninja() == tmp_path / binary_name
def test_find_ninja_package_not_installed() -> None:
"""A missing ninja package raises the actionable message, not ImportError."""
with (
patch("shutil.which", return_value=None),
patch.dict(sys.modules, {"ninja": None}),
pytest.raises(EsphomeError, match="ninja not found"),
):
ninja_helper.find_ninja()
def test_find_ninja_missing_everywhere(tmp_path: Path) -> None:
wheel = MagicMock(BIN_DIR=str(tmp_path))
with (
patch("shutil.which", return_value=None),
patch.dict(sys.modules, {"ninja": wheel}),
pytest.raises(EsphomeError, match="ninja not found"),
):
ninja_helper.find_ninja()
def test_escape_ninja_specials() -> None:
assert ninja_helper.escape("a b:c$d") == "a$ b$:c$$d"
def _q(tok: str) -> str:
"""The platform's shell_token quote wrapper (argv rule on Windows)."""
return f'"{tok}"' if os.name == "nt" else f"'{tok}'"
def test_quote_arg_windows_argv_rule() -> None:
# Backslash runs double only before a quote (subprocess.list2cmdline rule)
assert ninja_helper.quote_arg('-DX=a\\"b c') == '"-DX=a\\\\\\"b c"'
assert ninja_helper.quote_arg("a b\\") == '"a b\\\\"'
def test_shell_token_quotes_only_when_needed() -> None:
assert ninja_helper.shell_token("-Os") == "-Os"
assert ninja_helper.shell_token("-DP=C:\\x y") == _q("-DP=C:\\x y")
assert ninja_helper.shell_token("plain", force=True) == _q("plain")
def test_shell_token_quotes_shell_metacharacters() -> None:
"""Tokens like -DMASK=(1<<3) must not reach /bin/sh -c bare."""
assert ninja_helper.shell_token("-DMASK=(1<<3)") == _q("-DMASK=(1<<3)")
assert ninja_helper.shell_token("-DX=a;b") == _q("-DX=a;b")
assert ninja_helper.shell_token("-DX=$HOME") == _q("-DX=$$HOME")
def test_shell_token_posix_roundtrips_through_sh() -> None:
"""Backslash runs, $, backticks, and quotes must reach the compiler
exactly as lexed once ninja un-doubles $$ and /bin/sh strips quotes."""
if sys.platform == "win32":
pytest.skip("POSIX sh quoting")
for tok in ("-DP=a\\\\b", "-DX=$VAR", "-DY=`date`", "-DZ=it's", '-DC="q"'):
quoted = ninja_helper.shell_token(tok).replace("$$", "$")
out = subprocess.run(
["/bin/sh", "-c", f'printf "%s" {quoted}'],
capture_output=True,
text=True,
check=True,
)
assert out.stdout == tok
def test_quote_path_force_quotes() -> None:
assert ninja_helper.quote_path(Path("a b")) == _q("a b")
assert ninja_helper.quote_path("simple") == _q("simple")
def test_shell_token_empty_token_is_quoted() -> None:
"""An empty argv element must survive as an explicit pair of quotes."""
assert ninja_helper.shell_token("") == _q("")
def test_find_ninja_probes_path_hit(tmp_path: Path) -> None:
"""A broken PATH shim falls back to the wheel instead of failing every
build later."""
binary_name = "ninja.exe" if os.name == "nt" else "ninja"
(tmp_path / binary_name).touch()
wheel = MagicMock(BIN_DIR=str(tmp_path))
with (
patch("shutil.which", return_value="/broken/ninja"),
patch.object(ninja_helper, "_ninja_runs", return_value=False),
patch.dict(sys.modules, {"ninja": wheel}),
):
assert ninja_helper.find_ninja() == tmp_path / binary_name
def test_ninja_probe_failure_warns(caplog: pytest.LogCaptureFixture) -> None:
with patch("esphome.framework_helpers.subprocess.run", side_effect=OSError("boom")):
assert ninja_helper._ninja_runs("/broken/ninja") is False
assert "failed to run" in caplog.text
def test_ninja_probe_success() -> None:
with patch("esphome.framework_helpers.subprocess.run") as mock_run:
assert ninja_helper._ninja_runs("/usr/bin/ninja") is True
assert mock_run.call_args.kwargs["close_fds"] is False
def test_shell_token_windows_branch_uses_argv_rule() -> None:
"""The nt branch quotes with the CreateProcess argv rule (the ubuntu
coverage run never takes it naturally)."""
with patch.object(os, "name", "nt"):
assert ninja_helper.shell_token("a b") == '"a b"'
assert ninja_helper.shell_token("", force=True) == '""'
@pytest.mark.parametrize("char", ["|", "\n", "\r"])
def test_escape_rejects_what_ninja_cannot_express(char: str) -> None:
with pytest.raises(EsphomeError, match="a ninja build file cannot express"):
ninja_helper.escape(f"src/a{char}b.cpp")
def test_escape_keeps_a_hash() -> None:
"""A # is only a comment at the start of a ninja line."""
assert ninja_helper.escape("src/a#b c.cpp") == "src/a#b$ c.cpp"
def test_absolute_tool_anchors_a_relative_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Anchor the path: ninja runs from the build directory."""
monkeypatch.chdir(tmp_path)
assert Path(ninja_helper.absolute_tool("bin/ninja")) == tmp_path / "bin" / "ninja"
def test_find_ninja_returns_an_absolute_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
with (
patch("shutil.which", return_value="bin/ninja"),
patch.object(ninja_helper, "_ninja_runs", return_value=True),
):
assert ninja_helper.find_ninja() == tmp_path / "bin" / "ninja"
@pytest.fixture
def compdb_dir(tmp_path: Path) -> Path:
build_dir = tmp_path / "build"
build_dir.mkdir()
(build_dir / "build.ninja").write_text("rule x\n")
return build_dir
def test_refresh_compile_commands_regenerates_when_stale(compdb_dir: Path) -> None:
ninja = Path("ninja")
compdb = compdb_dir / "compile_commands.json"
stamp = compdb_dir / ".compile_commands.stamp"
with patch.object(ninja_helper, "write_compile_commands") as write:
# A rewritten manifest always regenerates
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, True)
assert write.call_count == 1
assert stamp.is_file()
# No compile DB yet
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False)
assert write.call_count == 2
compdb.write_text("[]")
# Fresh stamp: nothing to do
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False)
assert write.call_count == 2
# A manifest newer than the stamp (interrupted previous run)
os.utime(stamp, (1, 1))
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False)
assert write.call_count == 3
# A missing stamp regenerates too
stamp.unlink()
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False)
assert write.call_count == 4
def _completed(rc: int = 0, stdout: str = "", stderr: str = "") -> MagicMock:
return MagicMock(returncode=rc, stdout=stdout, stderr=stderr)
def test_write_compile_commands_success(compdb_dir: Path) -> None:
entries = '[{"file": "a.cpp", "command": "g++ -c a.cpp"}]'
with patch("subprocess.run", return_value=_completed(stdout=entries)) as run:
ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {"A": "1"})
assert (compdb_dir / "compile_commands.json").read_text() == entries
# Every compile rule, so preprocessed assembly reaches the database too
assert run.call_args.args[0] == [
"ninja",
"-C",
str(compdb_dir),
"-t",
"compdb",
"c",
"cxx",
"aspp",
"asm",
]
assert run.call_args.kwargs["env"] == {"A": "1"}
@pytest.mark.parametrize(
("result", "message"),
[
(_completed(rc=1, stderr="boom"), "Could not generate compile_commands.json"),
# A parse failure names its cause, not the rule-name story
(_completed(stdout="not json"), "unparsable compile database.*not json"),
(_completed(stdout="[]"), "empty compile database"),
],
)
def test_write_compile_commands_failures_drop_stale_db(
compdb_dir: Path, result: MagicMock, message: str
) -> None:
compdb = compdb_dir / "compile_commands.json"
compdb.write_text("[stale]")
with (
patch("subprocess.run", return_value=result),
pytest.raises(EsphomeError, match=message),
):
ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {})
assert not compdb.exists()
def test_write_compile_commands_keeps_the_mtime_of_an_unchanged_db(
compdb_dir: Path,
) -> None:
"""The idedata cache is keyed on the DB's mtime."""
entries = '[{"file": "a.cpp", "command": "g++ -c a.cpp"}]'
compdb = compdb_dir / "compile_commands.json"
compdb.write_text(entries)
os.utime(compdb, (1000, 1000))
with patch("subprocess.run", return_value=_completed(stdout=entries)):
ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {})
assert compdb.stat().st_mtime == 1000