Compare commits

..

4 Commits

Author SHA1 Message Date
J. Nick Koston
037ef732a8 cover 2025-12-01 22:43:14 -06:00
Jonathan Swoboda
629ebdd794 Fix 2025-12-01 23:32:36 -05:00
Jonathan Swoboda
fa2d206d74 Clean up 2025-12-01 22:56:57 -05:00
Jonathan Swoboda
4be240782e Filter noisy platformio log messages
Add a logging filter to suppress "Package configuration completed
successfully" messages from platformio platform modules. The existing
FILTER_PLATFORMIO_LINES mechanism only filters stdout/stderr, but
platformio's logger.info() calls bypass this by using Python's logging
module with handlers that reference the original streams.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 23:33:51 -05:00
4 changed files with 184 additions and 134 deletions

View File

@@ -107,9 +107,32 @@ FILTER_PLATFORMIO_LINES = [
r"Warning: DEPRECATED: 'esptool.py' is deprecated. Please use 'esptool' instead. The '.py' suffix will be removed in a future major release.",
r"Warning: esp-idf-size exited with code 2",
r"esp_idf_size: error: unrecognized arguments: --ng",
r"Package configuration completed successfully",
]
class PlatformioLogFilter(logging.Filter):
"""Filter to suppress noisy platformio log messages."""
_PATTERN = re.compile(
r"|".join(r"(?:" + pattern + r")" for pattern in FILTER_PLATFORMIO_LINES)
)
def filter(self, record: logging.LogRecord) -> bool:
# Only filter messages from platformio-related loggers
if "platformio" not in record.name.lower():
return True
return self._PATTERN.match(record.getMessage()) is None
def patch_platformio_logging() -> None:
"""Add filter to root logger handlers to suppress noisy platformio messages."""
root_logger = logging.getLogger()
for handler in root_logger.handlers:
if not any(isinstance(f, PlatformioLogFilter) for f in handler.filters):
handler.addFilter(PlatformioLogFilter())
def run_platformio_cli(*args, **kwargs) -> str | int:
os.environ["PLATFORMIO_FORCE_COLOR"] = "true"
os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute())
@@ -130,6 +153,8 @@ def run_platformio_cli(*args, **kwargs) -> str | int:
patch_structhash()
patch_file_downloader()
if not CORE.verbose:
patch_platformio_logging()
return run_external_command(platformio.__main__.main, *cmd, **kwargs)

View File

@@ -1,12 +1,8 @@
from collections.abc import Callable
import importlib
import logging
import os
from pathlib import Path
import re
import shutil
import stat
from types import TracebackType
from esphome import loader
from esphome.config import iter_component_configs, iter_components
@@ -305,24 +301,9 @@ def clean_cmake_cache():
pioenvs_cmake_path.unlink()
def _rmtree_error_handler(
func: Callable[[str], object],
path: str,
exc_info: tuple[type[BaseException], BaseException, TracebackType | None],
) -> None:
"""Error handler for shutil.rmtree to handle read-only files on Windows.
On Windows, git pack files and other files may be marked read-only,
causing shutil.rmtree to fail with "Access is denied". This handler
removes the read-only flag and retries the deletion.
"""
if os.access(path, os.W_OK):
raise exc_info[1].with_traceback(exc_info[2])
os.chmod(path, stat.S_IWUSR | stat.S_IRUSR)
func(path)
def clean_build(clear_pio_cache: bool = True):
import shutil
# Allow skipping cache cleaning for integration tests
if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"):
_LOGGER.warning("Skipping build cleaning (ESPHOME_SKIP_CLEAN_BUILD set)")
@@ -331,11 +312,11 @@ def clean_build(clear_pio_cache: bool = True):
pioenvs = CORE.relative_pioenvs_path()
if pioenvs.is_dir():
_LOGGER.info("Deleting %s", pioenvs)
shutil.rmtree(pioenvs, onerror=_rmtree_error_handler)
shutil.rmtree(pioenvs)
piolibdeps = CORE.relative_piolibdeps_path()
if piolibdeps.is_dir():
_LOGGER.info("Deleting %s", piolibdeps)
shutil.rmtree(piolibdeps, onerror=_rmtree_error_handler)
shutil.rmtree(piolibdeps)
dependencies_lock = CORE.relative_build_path("dependencies.lock")
if dependencies_lock.is_file():
_LOGGER.info("Deleting %s", dependencies_lock)
@@ -356,10 +337,12 @@ def clean_build(clear_pio_cache: bool = True):
cache_dir = Path(config.get("platformio", "cache_dir"))
if cache_dir.is_dir():
_LOGGER.info("Deleting PlatformIO cache %s", cache_dir)
shutil.rmtree(cache_dir, onerror=_rmtree_error_handler)
shutil.rmtree(cache_dir)
def clean_all(configuration: list[str]):
import shutil
data_dirs = []
for config in configuration:
item = Path(config)
@@ -381,7 +364,7 @@ def clean_all(configuration: list[str]):
if item.is_file() and not item.name.endswith(".json"):
item.unlink()
elif item.is_dir() and item.name != "storage":
shutil.rmtree(item, onerror=_rmtree_error_handler)
shutil.rmtree(item)
# Clean PlatformIO project files
try:
@@ -395,7 +378,7 @@ def clean_all(configuration: list[str]):
path = Path(config.get("platformio", pio_dir))
if path.is_dir():
_LOGGER.info("Deleting PlatformIO %s %s", pio_dir, path)
shutil.rmtree(path, onerror=_rmtree_error_handler)
shutil.rmtree(path)
GITIGNORE_CONTENT = """# Gitignore settings for ESPHome

View File

@@ -1,6 +1,7 @@
"""Tests for platformio_api.py path functions."""
import json
import logging
import os
from pathlib import Path
import shutil
@@ -670,3 +671,152 @@ def test_process_stacktrace_bad_alloc(
assert "Memory allocation of 512 bytes failed at 40201234" in caplog.text
mock_decode_pc.assert_called_once_with(config, "40201234")
assert state is False
def test_platformio_log_filter_allows_non_platformio_messages() -> None:
"""Test that non-platformio logger messages are allowed through."""
log_filter = platformio_api.PlatformioLogFilter()
record = logging.LogRecord(
name="esphome.core",
level=logging.INFO,
pathname="",
lineno=0,
msg="Some esphome message",
args=(),
exc_info=None,
)
assert log_filter.filter(record) is True
@pytest.mark.parametrize(
"msg",
[
pytest.param(
"Verbose mode can be enabled via `-v, --verbose` option", id="verbose_mode"
),
pytest.param("Found 5 compatible libraries", id="found_5_libs"),
pytest.param("Found 123 compatible libraries", id="found_123_libs"),
pytest.param("Building in release mode", id="release_mode"),
pytest.param("Building in debug mode", id="debug_mode"),
pytest.param("Merged 2 ELF section", id="merged_elf"),
pytest.param("esptool.py v4.7.0", id="esptool_py"),
pytest.param("esptool v4.8.1", id="esptool"),
pytest.param("PLATFORM: espressif32 @ 6.4.0", id="platform"),
pytest.param("Using cache: /path/to/cache", id="cache"),
pytest.param("Package configuration completed successfully", id="pkg_config"),
pytest.param("Scanning dependencies...", id="scanning_deps"),
pytest.param("Installing dependencies", id="installing_deps"),
pytest.param(
"Library Manager: Already installed, built-in library", id="lib_manager"
),
pytest.param(
"Memory Usage -> https://bit.ly/pio-memory-usage", id="memory_usage"
),
],
)
def test_platformio_log_filter_blocks_noisy_messages(msg: str) -> None:
"""Test that noisy platformio messages are filtered out."""
log_filter = platformio_api.PlatformioLogFilter()
record = logging.LogRecord(
name="platformio.builder",
level=logging.INFO,
pathname="",
lineno=0,
msg=msg,
args=(),
exc_info=None,
)
assert log_filter.filter(record) is False
@pytest.mark.parametrize(
"msg",
[
pytest.param("Compiling .pio/build/test/src/main.cpp.o", id="compiling"),
pytest.param("Linking .pio/build/test/firmware.elf", id="linking"),
pytest.param("Error: something went wrong", id="error"),
pytest.param("warning: unused variable", id="warning"),
],
)
def test_platformio_log_filter_allows_other_platformio_messages(msg: str) -> None:
"""Test that non-noisy platformio messages are allowed through."""
log_filter = platformio_api.PlatformioLogFilter()
record = logging.LogRecord(
name="platformio.builder",
level=logging.INFO,
pathname="",
lineno=0,
msg=msg,
args=(),
exc_info=None,
)
assert log_filter.filter(record) is True
@pytest.mark.parametrize(
"logger_name",
[
pytest.param("PLATFORMIO.builder", id="upper"),
pytest.param("PlatformIO.core", id="mixed"),
pytest.param("platformio.run", id="lower"),
],
)
def test_platformio_log_filter_case_insensitive_logger_name(logger_name: str) -> None:
"""Test that platformio logger name matching is case insensitive."""
log_filter = platformio_api.PlatformioLogFilter()
record = logging.LogRecord(
name=logger_name,
level=logging.INFO,
pathname="",
lineno=0,
msg="Found 5 compatible libraries",
args=(),
exc_info=None,
)
assert log_filter.filter(record) is False
def test_patch_platformio_logging_adds_filter() -> None:
"""Test that patch_platformio_logging adds filter to all handlers."""
test_handler = logging.StreamHandler()
root_logger = logging.getLogger()
original_handlers = root_logger.handlers.copy()
try:
root_logger.addHandler(test_handler)
assert not any(
isinstance(f, platformio_api.PlatformioLogFilter)
for f in test_handler.filters
)
platformio_api.patch_platformio_logging()
assert any(
isinstance(f, platformio_api.PlatformioLogFilter)
for f in test_handler.filters
)
finally:
root_logger.handlers = original_handlers
def test_patch_platformio_logging_no_duplicate_filters() -> None:
"""Test that patch_platformio_logging doesn't add duplicate filters."""
test_handler = logging.StreamHandler()
root_logger = logging.getLogger()
original_handlers = root_logger.handlers.copy()
try:
root_logger.addHandler(test_handler)
platformio_api.patch_platformio_logging()
platformio_api.patch_platformio_logging()
filter_count = sum(
1
for f in test_handler.filters
if isinstance(f, platformio_api.PlatformioLogFilter)
)
assert filter_count == 1
finally:
root_logger.handlers = original_handlers

View File

@@ -1,9 +1,7 @@
"""Test writer module functionality."""
from collections.abc import Callable
import os
from pathlib import Path
import stat
from typing import Any
from unittest.mock import MagicMock, patch
@@ -1064,109 +1062,3 @@ def test_clean_all_preserves_json_files(
# Verify logging mentions cleaning
assert "Cleaning" in caplog.text
assert str(build_dir) in caplog.text
@patch("esphome.writer.CORE")
def test_clean_build_handles_readonly_files(
mock_core: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test clean_build handles read-only files (e.g., git pack files on Windows)."""
# Create directory structure with read-only files
pioenvs_dir = tmp_path / ".pioenvs"
pioenvs_dir.mkdir()
git_dir = pioenvs_dir / ".git" / "objects" / "pack"
git_dir.mkdir(parents=True)
# Create a read-only file (simulating git pack files on Windows)
readonly_file = git_dir / "pack-abc123.pack"
readonly_file.write_text("pack data")
os.chmod(readonly_file, stat.S_IRUSR) # Read-only
# Setup mocks
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock"
# Verify file is read-only
assert not os.access(readonly_file, os.W_OK)
# Call the function - should not crash
with caplog.at_level("INFO"):
clean_build()
# Verify directory was removed despite read-only files
assert not pioenvs_dir.exists()
@patch("esphome.writer.CORE")
def test_clean_all_handles_readonly_files(
mock_core: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test clean_all handles read-only files."""
from esphome.writer import clean_all
# Create config directory
config_dir = tmp_path / "config"
config_dir.mkdir()
build_dir = config_dir / ".esphome"
build_dir.mkdir()
# Create a subdirectory with read-only files
subdir = build_dir / "subdir"
subdir.mkdir()
readonly_file = subdir / "readonly.txt"
readonly_file.write_text("content")
os.chmod(readonly_file, stat.S_IRUSR) # Read-only
# Verify file is read-only
assert not os.access(readonly_file, os.W_OK)
# Call the function - should not crash
with caplog.at_level("INFO"):
clean_all([str(config_dir)])
# Verify directory was removed despite read-only files
assert not subdir.exists()
assert build_dir.exists() # .esphome dir itself is preserved
@patch("esphome.writer.CORE")
def test_clean_build_reraises_for_other_errors(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test clean_build re-raises errors that are not read-only permission issues."""
# Create directory structure with a read-only subdirectory
# This prevents file deletion and triggers the error handler
pioenvs_dir = tmp_path / ".pioenvs"
pioenvs_dir.mkdir()
subdir = pioenvs_dir / "subdir"
subdir.mkdir()
test_file = subdir / "test.txt"
test_file.write_text("content")
# Make subdir read-only so files inside can't be deleted
os.chmod(subdir, stat.S_IRUSR | stat.S_IXUSR)
# Setup mocks
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock"
try:
# Mock os.access in writer module to return True (writable)
# This simulates a case where the error is NOT due to read-only permissions
# so the error handler should re-raise instead of trying to fix permissions
with (
patch("esphome.writer.os.access", return_value=True),
pytest.raises(PermissionError),
):
clean_build()
finally:
# Cleanup - restore write permission so tmp_path cleanup works
os.chmod(subdir, stat.S_IRWXU)