Compare commits

..
Author SHA1 Message Date
J. Nick Koston 2764f9d938 [snapshot] Keep the fallback name literal in flash on ESP8266 2026-09-08 17:59:23 +02:00
J. Nick Koston bb5481a58d Merge remote-tracking branch 'upstream/dev' into logstr-literal-lint 2026-09-08 17:59:23 +02:00
J. Nick Koston 18b5baa76b Merge remote-tracking branch 'upstream/dev' into logstr-literal-lint
# Conflicts:
#	tests/script/test_ci_custom.py
2026-09-02 11:24:30 +02:00
J. Nick Koston 894e6fa2b8 Name the raw string delimiter group and pin the lint invariants in tests 2026-08-31 07:45:59 -05:00
J. Nick Koston 524fc453c1 Report unbalanced log calls once, honour only bare NOLINT, handle raw strings and comments before branches 2026-08-30 22:26:43 -05:00
J. Nick Koston 68bac9f315 Harden the log call scanner and add unit tests 2026-08-30 21:59:52 -05:00
J. Nick Koston 8546c7228a Exempt empty literals from the log literal lint 2026-08-30 21:42:29 -05:00
J. Nick Koston f7b078cfed Keep zwave_proxy in the log literal lint 2026-08-30 21:36:06 -05:00
J. Nick Koston 4fabd4d069 Show the offending literal in the log literal lint message 2026-08-30 21:27:25 -05:00
J. Nick Koston 296a691aa4 Skip lvgl in the log literal lint 2026-08-30 21:26:15 -05:00
J. Nick Koston f25c4ef787 Skip zwave_proxy in the log literal lint 2026-08-30 21:23:13 -05:00
J. Nick Koston b96b939ad9 Skip sources that never build for ESP8266 in the log literal lint 2026-08-30 21:20:18 -05:00
J. Nick Koston 4bd3ce97f8 Bound log calls by their closing paren and simplify the ternary literal scan 2026-08-30 21:18:00 -05:00
J. Nick Koston d7c5cd0a68 Add type hints to the log literal lint 2026-08-30 21:11:23 -05:00
J. Nick Koston cda1fe5233 [core] Lint bare string literal ternaries in ESP_LOG arguments 2026-08-30 21:09:51 -05:00
5 changed files with 279 additions and 77 deletions
+1 -1
View File
@@ -211,7 +211,7 @@ bool Snapshot::take_snapshot(const char *filename) {
exact = !name.empty();
if (name_changed) {
ESP_LOGW(TAG, "Requested snapshot name '%s' is not an acceptable file name, using '%s' instead", filename,
name.empty() ? "a name made from the time" : name.c_str());
name.empty() ? LOG_STR_LITERAL("a name made from the time") : name.c_str());
}
}
if (name.empty()) {
+9 -32
View File
@@ -371,27 +371,14 @@ def _registry_jobs(
return jobs, failed, installable
def _is_vcs_spec_uri(url: str) -> bool:
"""Whether pio's ``install_from_uri`` would clone this URI rather than
copy or download it (PackageSpec normalizes git URLs to ``git+``)."""
return not url.startswith(("file://", "symlink://", "http://", "https://"))
def _spec_name(spec: Any, url: str) -> str:
"""The spec's name; the URL basename fallback is defensive only
(PackageSpec derives a name from the URI itself)."""
return spec.name or url.split("#", 1)[0].rsplit("/", 1)[-1]
def _uri_jobs(
manager: Any, specs: list[Any], seen: set[str]
) -> tuple[list[tuple[str, int, Any]], int, list[tuple[str, Any]]]:
"""Jobs for direct-URL specs; a HEAD sizes each for the combined bar.
Also returns how many HEAD probes errored (an absent length is not an
error) and the ``(name, spec)`` pairs to pre-install: downloaded
archives, plus VCS specs, which have no archive -- the pre-install
itself clones them, in parallel instead of one at a time in pio run.
error) and the ``(name, spec)`` pairs whose archives will be
installable.
"""
from esphome.net_retry import fetch_with_retry, http_request
@@ -399,17 +386,13 @@ def _uri_jobs(
installable: list[tuple[str, Any]] = []
for spec in specs:
url = spec.uri
if not url:
continue
is_vcs = _is_vcs_spec_uri(url)
if not is_vcs and not url.startswith(("http://", "https://")):
continue # file/symlink specs are copied in place by pio run
if not url or not url.startswith(("http://", "https://")):
continue # git+/file specs are cloned/copied, not downloaded
if url.split("#", 1)[0].endswith(".git"):
continue # bare-URL VCS spec; PlatformIO clones it
if manager.get_package(spec):
continue
name = _spec_name(spec, url)
if is_vcs:
installable.append((name, spec))
continue
name = spec.name or url.rsplit("/", 1)[-1]
# PlatformIO downloads URL specs with no checksum
dl_path = Path(manager.compute_download_path(url, ""))
if dl_path.is_file():
@@ -423,7 +406,7 @@ def _uri_jobs(
if str(dl_path) in seen:
continue # another spec already claimed this .part
seen.add(str(dl_path))
candidates.append((name, url, dl_path, spec))
candidates.append((spec.name, url, dl_path, spec))
errors: list[str] = []
@@ -932,14 +915,8 @@ def _prefetch(build_dir: Path, env: str) -> None:
if name not in failed_names
}
if to_install:
# Clones first: they wait on the network, so they must not
# queue behind CPU-bound archive extractions
ordered = sorted(
to_install.values(),
key=lambda entry: not ((url := entry[1].uri) and _is_vcs_spec_uri(url)),
)
try:
_preinstall(mgr, ordered)
_preinstall(mgr, list(to_install.values()))
if is_platform:
platform_packages_installed = True
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
+140 -6
View File
@@ -3,6 +3,7 @@
import argparse
import codecs
import collections
from collections.abc import Iterator
import fnmatch
import functools
import os.path
@@ -1135,7 +1136,56 @@ def lint_no_std_bind(fname, match):
)
LOG_MULTILINE_RE = re.compile(r"ESP_LOG\w+\s*\(.*?;", re.DOTALL)
LOG_CALL_START_RE = re.compile(r"ESP_LOG\w+\s*\(")
# Comments, raw/plain string literals and single char literals are consumed whole so ; ( ) ? :
# inside them are never seen. A char literal is exactly one (escaped) char so a digit separator
# like 1'000'000 cannot open one.
CPP_COMMENT_RE = r"//[^\n]*|/\*.*?\*/"
CPP_SKIP_RE = (
CPP_COMMENT_RE
+ r'|R"(?P<raw_delim>[^(\s]*)\(.*?\)(?P=raw_delim)"|"(?:[^"\\]|\\.)*"|\'(?:[^\'\\\n]|\\.)\''
)
LOG_CALL_TOKEN_RE = re.compile(CPP_SKIP_RE + r"|[()]", re.DOTALL)
# The last alternative matches a ? or : followed (after spaces or comments) by an opening quote,
# i.e. a string literal used as a ternary branch.
LOG_TERNARY_LITERAL_RE = re.compile(
CPP_SKIP_RE + r"|[?:](?:\s|" + CPP_COMMENT_RE + r')*(?=")', re.DOTALL
)
# A bare NOLINT; a clang-tidy NOLINT(check-name) is aimed at a different tool.
NOLINT_RE = re.compile(r"\bNOLINT\b(?!\()")
def _line_col(content: str, pos: int) -> tuple[int, int]:
"""1-based line and column of an offset in content."""
return content.count("\n", 0, pos) + 1, pos - content.rfind("\n", 0, pos)
def _iter_log_calls(content: str) -> Iterator[tuple[int, str | None]]:
"""Yield (start, text) for every ESP_LOG*(...) call, text running to the matching close paren.
text is None when no matching paren exists so callers can report the call instead of skipping it."""
for head in LOG_CALL_START_RE.finditer(content):
depth = 1
for tok in LOG_CALL_TOKEN_RE.finditer(content, head.end()):
if tok.group(0) == "(":
depth += 1
elif tok.group(0) == ")":
depth -= 1
if depth == 0:
yield head.start(), content[head.start() : tok.end()]
break
else:
yield head.start(), None
def _unbalanced_log_call_error(content: str, pos: int) -> tuple[int, int, str]:
lineno, col = _line_col(content, pos)
return (
lineno,
col,
"ESP_LOG call has no matching closing parenthesis, so it cannot be checked.",
)
LOG_BAD_CONTINUATION_RE = re.compile(r'\\n(?:[^ \\"\r\n\t]|"\s*\n\s*"[^ \\])')
LOG_PERCENT_S_CONTINUATION_RE = re.compile(r'\\n(?:%s|"\s*\n\s*"%s)')
@@ -1143,16 +1193,16 @@ LOG_PERCENT_S_CONTINUATION_RE = re.compile(r'\\n(?:%s|"\s*\n\s*"%s)')
@lint_content_check(include=cpp_include)
def lint_log_multiline_continuation(fname, content):
errs = []
for log_match in LOG_MULTILINE_RE.finditer(content):
log_text = log_match.group(0)
for log_start, log_text in _iter_log_calls(content):
if log_text is None:
errs.append(_unbalanced_log_call_error(content, log_start))
continue
for bad_match in LOG_BAD_CONTINUATION_RE.finditer(log_text):
# %s may expand to a whitespace prefix at runtime, skip those
if LOG_PERCENT_S_CONTINUATION_RE.match(log_text, bad_match.start()):
continue
# Calculate line number from position in full content
abs_pos = log_match.start() + bad_match.start()
lineno = content.count("\n", 0, abs_pos) + 1
col = abs_pos - content.rfind("\n", 0, abs_pos)
lineno, col = _line_col(content, log_start + bad_match.start())
errs.append(
(
lineno,
@@ -1170,6 +1220,90 @@ def lint_log_multiline_continuation(fname, content):
return errs
def _find_ternary_literals(text: str) -> Iterator[tuple[int, str]]:
"""Yield (offset, literal) for every string literal used as a ternary branch."""
branch = False
for m in LOG_TERNARY_LITERAL_RE.finditer(text):
tok = m.group(0)
# An empty literal is merged with every other string's terminator, so it costs no RAM,
# while a PSTR("") would add its own flash array; leave it alone.
if branch and tok[0] == '"' and tok != '""':
yield m.start(), tok
branch = tok[0] in "?:"
# LOG_STR_LITERAL is a no op everywhere except ESP8266, so code that never builds there is skipped
# to avoid churn: platform specific sources and components for ESP32, LibreTiny, RP2 and Zephyr only.
# A component belongs here only if it has no tests/components/<name>/test.esp8266-ard.yaml.
LOG_LITERAL_LINT_EXCLUDE = [
"*_esp32.cpp",
"*_esp32_*.cpp",
"*_esp_idf.cpp",
"*_rmt.cpp",
"*_zephyr.cpp",
"*_bk72xx.cpp",
"*_libretiny.cpp",
"*_pico_w.cpp",
"*_host.cpp",
"esphome/components/esp32*/*",
"esphome/components/bk72xx*/*",
"esphome/components/ln882h*/*",
"esphome/components/ln882x*/*",
"esphome/components/rp2*/*",
"esphome/components/zephyr*/*",
"esphome/components/host/*",
"esphome/components/libretiny*/*",
"esphome/components/bluetooth_proxy/*",
"esphome/components/bluetooth_connection/*",
"esphome/components/ble_client/*",
"esphome/components/bedjet/*",
"esphome/components/anova/*",
"esphome/components/xiaomi_ble/*",
"esphome/components/bthome_mithermometer/*",
"esphome/components/usb_host/*",
"esphome/components/zigbee/*",
"esphome/components/lvgl/*",
# Test fixtures and host only unit tests - not production embedded code
"tests/integration/fixtures/*",
"tests/components/*",
]
@lint_content_check(include=cpp_include, exclude=LOG_LITERAL_LINT_EXCLUDE)
def lint_log_no_bare_literal_ternary(
fname: Path, content: str
) -> list[tuple[int, int, str]]:
errs = []
for log_start, log_text in _iter_log_calls(content):
if log_text is None:
continue # reported by lint_log_multiline_continuation, which sees every file
# A NOLINT anywhere on the lines the call spans silences every branch in it
first_line = content.rfind("\n", 0, log_start) + 1
last_line = content.find("\n", log_start + len(log_text))
if NOLINT_RE.search(
content[first_line : last_line if last_line != -1 else None]
):
continue
for offset, literal in _find_ternary_literals(log_text):
lineno, col = _line_col(content, log_start + offset)
errs.append(
(
lineno,
col,
(
"String literal used as a ternary branch in a log call. On ESP8266 the "
"log macro moves the format string to flash, but bare literal arguments "
"stay in RAM. Wrap each branch passed straight to the log call in "
f"{highlight('LOG_STR_LITERAL(...)')}:\n"
f" Before: {highlight(literal)}\n"
f" After: {highlight(f'LOG_STR_LITERAL({literal})')}\n"
f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)"
),
)
)
return errs
@lint_content_find_check(
"ESP_LOG",
include=["*.h", "*.tcc"],
+126
View File
@@ -4,12 +4,16 @@ The rule flags an if/else/for/while whose only body is an unbraced ESP_LOG*() ca
empty statement -- and a -Wempty-body warning -- once the log level compiles the macro out). These
tests pin the comment/string/raw-string masker, the accepted control-statement shapes, and the
NOLINT escape hatch at both placements a contributor would try.
Also covers the ESP_LOG call scanner (_iter_log_calls) and the bare-literal-ternary lint.
"""
import importlib.util
from pathlib import Path
import sys
import pytest
SCRIPT_DIR = (Path(__file__).parent / ".." / ".." / "script").resolve()
sys.path.insert(0, str(SCRIPT_DIR))
_spec = importlib.util.spec_from_file_location("ci_custom", SCRIPT_DIR / "ci-custom.py")
@@ -145,3 +149,125 @@ def test_nolint_at_end_of_log_line_suppresses() -> None:
def test_nolint_on_control_line_suppresses() -> None:
assert not _lint("if (x) // NOLINT\n ESP_LOGD(t);\n")
# --- ESP_LOG call scanner and bare-literal-ternary lint ---
def _calls(content: str) -> list[str | None]:
return [text for _, text in ci_custom._iter_log_calls(content)]
def _ternary_errors(content: str) -> list[tuple[int, int]]:
errs = ci_custom.lint_log_no_bare_literal_ternary(Path("x.cpp"), content)
return [(line, col) for line, col, _ in errs]
@pytest.mark.parametrize(
"content",
[
'ESP_LOGD(TAG, "a ) b ( c; d")',
'ESP_LOGD(TAG, "quote \\" inside")',
"ESP_LOGD(TAG, \"%s\", format_hex_pretty(x, '-', false).c_str())",
"ESP_LOGD(TAG, \"%c%c\", '(', ')')",
"ESP_LOGD(TAG, \"%d\", 1'000'000)",
'ESP_LOGD(TAG, // it\'s a comment with ) and (\n "x")',
'ESP_LOGD(TAG, /* :) */ "x")',
'ESP_LOGD(TAG, "%s", R"(say "hi" :) )")',
'ESP_LOGD(TAG, "%s", R"x(a)"b)x")',
],
)
def test_iter_log_calls_spans_whole_call(content: str) -> None:
calls = _calls(content + ";\nint other = (1);")
assert calls == [content]
def test_iter_log_calls_reports_unbalanced_call_once() -> None:
content = 'ESP_LOGD(TAG, "x";\nvoid f();'
assert _calls(content) == [None]
errs = ci_custom.lint_log_multiline_continuation(Path("x.cpp"), content)
assert len(errs) == 1
assert errs[0][:2] == (1, 1)
assert "no matching closing parenthesis" in errs[0][2]
assert _ternary_errors(content) == []
@pytest.mark.parametrize(
("content", "expected"),
[
# A ; inside the format string no longer cuts the call short
('ESP_LOGD(TAG, "a; b\\nc %s", x);', [(1, 20)]),
# A \n%s continuation is exempt since %s may expand to leading whitespace
('ESP_LOGD(TAG, "a\\n%s", x);', []),
('ESP_LOGD(TAG, "a\\n b");', []),
],
)
def test_multiline_continuation_detection(
content: str, expected: list[tuple[int, int]]
) -> None:
errs = ci_custom.lint_log_multiline_continuation(Path("x.cpp"), content)
assert [(line, col) for line, col, _ in errs] == expected
def test_exclusion_list_only_names_components_without_esp8266_tests() -> None:
root = Path(__file__).parent / ".." / ".."
for pattern in ci_custom.LOG_LITERAL_LINT_EXCLUDE:
if not pattern.startswith("esphome/components/"):
continue
prefix = pattern.removeprefix("esphome/components/").split("/")[0]
comps = list((root / "esphome" / "components").glob(prefix))
assert comps, f"{pattern!r} matches no component"
for comp in comps:
test = root / "tests" / "components" / comp.name / "test.esp8266-ard.yaml"
assert not test.exists(), (
f"{comp.name} builds for ESP8266, drop {pattern!r}"
)
def test_unbalanced_calls_are_reported_by_a_check_that_sees_every_file() -> None:
# lint_log_no_bare_literal_ternary skips unbalanced calls and relies on this
checks = {c["func"].__name__: c for c in ci_custom.LINT_CONTENT_CHECKS}
continuation = checks["lint_log_multiline_continuation"]
ternary = checks["lint_log_no_bare_literal_ternary"]
assert continuation["exclude"] == []
assert continuation["include"] == ternary["include"]
@pytest.mark.parametrize(
("content", "expected"),
[
('ESP_LOGD(TAG, "%s", x ? "on" : "off");', [(1, 25), (1, 32)]),
(
'ESP_LOGD(TAG, "%s", x ? LOG_STR_LITERAL("on") : LOG_STR_LITERAL("off"));',
[],
),
('ESP_LOGD(TAG, "%s", x ? LOG_STR_LITERAL("on") : "off");', [(1, 49)]),
('ESP_LOGD(TAG, "%s", x ? "on" : "");', [(1, 25)]),
(
'ESP_LOGD(TAG, "%s",\n x ? "yes"\n : "no");',
[(2, 14), (3, 14)],
),
("ESP_LOGD(TAG, \"%c\", x ? '1' : '0');", []),
('ESP_LOGD(TAG, "a ? b : c %s", x ? "on" : "off");', [(1, 35), (1, 42)]),
('ESP_LOGD(TAG, "x:" "y %s", p);', []),
('ESP_LOGD(TAG, "%s", x ? "on" : "off"); // NOLINT', []),
('ESP_LOGD(TAG, "%s",\n x ? "yes"\n : "no"); // NOLINT', []),
('ESP_LOGD(TAG, "%s", x ? /* c */ "on" : "off");', [(1, 33), (1, 40)]),
(
'ESP_LOGD(TAG, "%s",\n x ? "on" // NOLINT(some-clang-check)\n : "off");',
[(2, 14), (3, 14)],
),
],
)
def test_ternary_literal_detection(
content: str, expected: list[tuple[int, int]]
) -> None:
assert _ternary_errors(content) == expected
def test_ternary_error_message_names_the_literal() -> None:
errs = ci_custom.lint_log_no_bare_literal_ternary(
Path("x.cpp"), 'ESP_LOGD(TAG, "%s", x ? "enabled" : LOG_STR_LITERAL("off"));'
)
assert len(errs) == 1
assert 'LOG_STR_LITERAL("enabled")' in errs[0][2]
+3 -38
View File
@@ -661,8 +661,7 @@ def test_registry_jobs_one_bad_spec_keeps_the_rest(tmp_path: Path) -> None:
def test_uri_jobs_head_sizes_the_bar(tmp_path: Path) -> None:
"""HEAD sizes direct-URL specs; VCS specs skip the download but are
still installable (the pre-install clones them in parallel)."""
"""HEAD sizes direct-URL specs; git and unreachable URLs are skipped."""
m = _fake_manager(tmp_path)
resp = MagicMock()
resp.headers = {"content-length": "2222"}
@@ -672,13 +671,14 @@ def test_uri_jobs_head_sizes_the_bar(tmp_path: Path) -> None:
[
_FakeSpec(uri="https://x/big.zip", name="big", custom_name=True),
_FakeSpec(uri="git+https://x/repo.git", name="repo"),
_FakeSpec(uri="https://x/repo.git#v1", name="barevcs"),
_FakeSpec(name="registry"),
],
set(),
)
assert failed == 0
assert [(n, s) for n, s, _ in jobs] == [("big", 2222)]
assert [n for n, _ in installable] == ["repo", "big"]
assert [n for n, _ in installable] == ["big"]
# a successful HEAD with no Content-Length is a clean skip
resp.headers = {}
with patch("esphome.net_retry.http_request", return_value=resp):
@@ -687,35 +687,6 @@ def test_uri_jobs_head_sizes_the_bar(tmp_path: Path) -> None:
) == ([], 0, [])
def test_uri_jobs_vcs_specs_installable_without_probe(tmp_path: Path) -> None:
"""VCS specs never probe the network here (there is no archive); an
uninstalled one is handed to the pre-install, an installed one and
file/symlink specs are skipped."""
m = _fake_manager(tmp_path)
with patch("esphome.net_retry.http_request") as mock_head:
jobs, failed, installable = pf._uri_jobs(
m,
[
_FakeSpec(uri="git+https://x/tool.git#1.0", name="tool"),
_FakeSpec(uri="hg+https://x/old", name="mercurial"),
# Name falls back to the URL basename, fragment excluded
_FakeSpec(uri="git+https://x/noname#v2", name=None),
_FakeSpec(uri="file:///local/dir", name="local"),
_FakeSpec(uri="symlink:///local/dir", name="link"),
],
set(),
)
mock_head.assert_not_called()
assert (jobs, failed) == ([], 0)
assert [n for n, _ in installable] == ["tool", "mercurial", "noname"]
m.get_package.return_value = object() # already installed: warm and silent
with patch("esphome.net_retry.http_request"):
assert pf._uri_jobs(
m, [_FakeSpec(uri="git+https://x/tool.git#1.0", name="tool")], set()
) == ([], 0, [])
def test_uri_jobs_head_failure_counts_as_unresolved(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
@@ -1919,9 +1890,3 @@ def test_platformio_private_api_contract() -> None:
derived = PackageSpec("https://x/y/archive/master.zip")
assert derived.name and not derived.has_custom_name()
assert PackageSpec("Foo=https://x/y/archive/master.zip").has_custom_name()
# _is_vcs_spec_uri relies on bare .git URLs normalizing to git+, on
# both parse paths (raw string, and requirements= for platform tools)
assert PackageSpec("https://github.com/x/y.git#v1").uri.startswith("git+")
assert PackageSpec(
owner="o", name="tool-x", requirements="https://github.com/x/y.git"
).uri.startswith("git+")