Files
esphome/tests/unit_tests/test_util.py
T
J. Nick Koston aec76fafce [rp2040] Use picotool for BOOTSEL upload instead of mass storage copy
Replace UF2 file copy to mass storage volume with direct picotool upload.
This avoids macOS "disk not ejected properly" warnings caused by the RP2040
resetting immediately after receiving the firmware.

- Use picotool (already installed by PlatformIO) for BOOTSEL detection
  and firmware upload via USB
- Upload ELF directly with `picotool load -v -x` for real-time progress
- Remove platform-specific mass storage volume detection (macOS/Linux/Windows)
- Show helpful udev rules message on Linux permission errors
2026-03-05 12:59:07 -10:00

501 lines
16 KiB
Python

"""Tests for esphome.util module."""
from __future__ import annotations
from pathlib import Path
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from esphome import util
def test_list_yaml_files_with_files_and_directories(tmp_path: Path) -> None:
"""Test that list_yaml_files handles both files and directories."""
# Create directory structure
dir1 = tmp_path / "configs"
dir1.mkdir()
dir2 = tmp_path / "more_configs"
dir2.mkdir()
# Create YAML files in directories
(dir1 / "config1.yaml").write_text("test: 1")
(dir1 / "config2.yml").write_text("test: 2")
(dir1 / "not_yaml.txt").write_text("not yaml")
(dir2 / "config3.yaml").write_text("test: 3")
# Create standalone YAML files
standalone1 = tmp_path / "standalone.yaml"
standalone1.write_text("test: 4")
standalone2 = tmp_path / "another.yml"
standalone2.write_text("test: 5")
# Test with mixed input (directories and files)
configs = [
dir1,
standalone1,
dir2,
standalone2,
]
result = util.list_yaml_files(configs)
# Should include all YAML files but not the .txt file
assert set(result) == {
dir1 / "config1.yaml",
dir1 / "config2.yml",
dir2 / "config3.yaml",
standalone1,
standalone2,
}
# Check that results are sorted
assert result == sorted(result)
def test_list_yaml_files_only_directories(tmp_path: Path) -> None:
"""Test list_yaml_files with only directories."""
dir1 = tmp_path / "dir1"
dir1.mkdir()
dir2 = tmp_path / "dir2"
dir2.mkdir()
(dir1 / "a.yaml").write_text("test: a")
(dir1 / "b.yml").write_text("test: b")
(dir2 / "c.yaml").write_text("test: c")
result = util.list_yaml_files([dir1, dir2])
assert set(result) == {
dir1 / "a.yaml",
dir1 / "b.yml",
dir2 / "c.yaml",
}
assert result == sorted(result)
def test_list_yaml_files_only_files(tmp_path: Path) -> None:
"""Test list_yaml_files with only files."""
file1 = tmp_path / "file1.yaml"
file2 = tmp_path / "file2.yml"
file3 = tmp_path / "file3.yaml"
non_yaml = tmp_path / "not_yaml.json"
file1.write_text("test: 1")
file2.write_text("test: 2")
file3.write_text("test: 3")
non_yaml.write_text("{}")
# Include a non-YAML file to test filtering
result = util.list_yaml_files(
[
file1,
file2,
file3,
non_yaml,
]
)
assert set(result) == {
file1,
file2,
file3,
}
assert result == sorted(result)
def test_list_yaml_files_empty_directory(tmp_path: Path) -> None:
"""Test list_yaml_files with an empty directory."""
empty_dir = tmp_path / "empty"
empty_dir.mkdir()
result = util.list_yaml_files([empty_dir])
assert result == []
def test_list_yaml_files_nonexistent_path(tmp_path: Path) -> None:
"""Test list_yaml_files with a nonexistent path raises an error."""
nonexistent = tmp_path / "nonexistent"
existing = tmp_path / "existing.yaml"
existing.write_text("test: 1")
# Should raise an error for non-existent directory
with pytest.raises(FileNotFoundError):
util.list_yaml_files([nonexistent, existing])
def test_list_yaml_files_mixed_extensions(tmp_path: Path) -> None:
"""Test that both .yaml and .yml extensions are recognized."""
dir1 = tmp_path / "configs"
dir1.mkdir()
yaml_file = dir1 / "config.yaml"
yml_file = dir1 / "config.yml"
other_file = dir1 / "config.txt"
yaml_file.write_text("test: yaml")
yml_file.write_text("test: yml")
other_file.write_text("test: txt")
result = util.list_yaml_files([dir1])
assert set(result) == {
yaml_file,
yml_file,
}
def test_list_yaml_files_does_not_recurse_into_subdirectories(tmp_path: Path) -> None:
"""Test that list_yaml_files only finds files in specified directory, not subdirectories."""
# Create directory structure with YAML files at different depths
root = tmp_path / "configs"
root.mkdir()
# Create YAML files in the root directory
(root / "config1.yaml").write_text("test: 1")
(root / "config2.yml").write_text("test: 2")
(root / "device.yaml").write_text("test: device")
# Create subdirectory with YAML files (should NOT be found)
subdir = root / "subdir"
subdir.mkdir()
(subdir / "nested1.yaml").write_text("test: nested1")
(subdir / "nested2.yml").write_text("test: nested2")
# Create deeper subdirectory (should NOT be found)
deep_subdir = subdir / "deeper"
deep_subdir.mkdir()
(deep_subdir / "very_nested.yaml").write_text("test: very_nested")
# Test listing files from the root directory
result = util.list_yaml_files([str(root)])
# Should only find the 3 files in root, not the 3 in subdirectories
assert len(result) == 3
# Check that only root-level files are found
assert root / "config1.yaml" in result
assert root / "config2.yml" in result
assert root / "device.yaml" in result
# Ensure nested files are NOT found
for r in result:
r_str = str(r)
assert "subdir" not in r_str
assert "deeper" not in r_str
assert "nested1.yaml" not in r_str
assert "nested2.yml" not in r_str
assert "very_nested.yaml" not in r_str
def test_list_yaml_files_excludes_secrets(tmp_path: Path) -> None:
"""Test that secrets.yaml and secrets.yml are excluded."""
root = tmp_path / "configs"
root.mkdir()
# Create various YAML files including secrets
(root / "config.yaml").write_text("test: config")
(root / "secrets.yaml").write_text("wifi_password: secret123")
(root / "secrets.yml").write_text("api_key: secret456")
(root / "device.yaml").write_text("test: device")
result = util.list_yaml_files([str(root)])
# Should find 2 files (config.yaml and device.yaml), not secrets
assert len(result) == 2
assert root / "config.yaml" in result
assert root / "device.yaml" in result
assert root / "secrets.yaml" not in result
assert root / "secrets.yml" not in result
def test_list_yaml_files_excludes_hidden_files(tmp_path: Path) -> None:
"""Test that hidden files (starting with .) are excluded."""
root = tmp_path / "configs"
root.mkdir()
# Create regular and hidden YAML files
(root / "config.yaml").write_text("test: config")
(root / ".hidden.yaml").write_text("test: hidden")
(root / ".backup.yml").write_text("test: backup")
(root / "device.yaml").write_text("test: device")
result = util.list_yaml_files([str(root)])
# Should find only non-hidden files
assert len(result) == 2
assert root / "config.yaml" in result
assert root / "device.yaml" in result
assert root / ".hidden.yaml" not in result
assert root / ".backup.yml" not in result
def test_filter_yaml_files_basic() -> None:
"""Test filter_yaml_files function."""
files = [
Path("/path/to/config.yaml"),
Path("/path/to/device.yml"),
Path("/path/to/readme.txt"),
Path("/path/to/script.py"),
Path("/path/to/data.json"),
Path("/path/to/another.yaml"),
]
result = util.filter_yaml_files(files)
assert len(result) == 3
assert Path("/path/to/config.yaml") in result
assert Path("/path/to/device.yml") in result
assert Path("/path/to/another.yaml") in result
assert Path("/path/to/readme.txt") not in result
assert Path("/path/to/script.py") not in result
assert Path("/path/to/data.json") not in result
def test_filter_yaml_files_excludes_secrets() -> None:
"""Test that filter_yaml_files excludes secrets files."""
files = [
Path("/path/to/config.yaml"),
Path("/path/to/secrets.yaml"),
Path("/path/to/secrets.yml"),
Path("/path/to/device.yaml"),
Path("/some/dir/secrets.yaml"),
]
result = util.filter_yaml_files(files)
assert len(result) == 2
assert Path("/path/to/config.yaml") in result
assert Path("/path/to/device.yaml") in result
assert Path("/path/to/secrets.yaml") not in result
assert Path("/path/to/secrets.yml") not in result
assert Path("/some/dir/secrets.yaml") not in result
def test_filter_yaml_files_excludes_hidden() -> None:
"""Test that filter_yaml_files excludes hidden files."""
files = [
Path("/path/to/config.yaml"),
Path("/path/to/.hidden.yaml"),
Path("/path/to/.backup.yml"),
Path("/path/to/device.yaml"),
Path("/some/dir/.config.yaml"),
]
result = util.filter_yaml_files(files)
assert len(result) == 2
assert Path("/path/to/config.yaml") in result
assert Path("/path/to/device.yaml") in result
assert Path("/path/to/.hidden.yaml") not in result
assert Path("/path/to/.backup.yml") not in result
assert Path("/some/dir/.config.yaml") not in result
def test_filter_yaml_files_case_sensitive() -> None:
"""Test that filter_yaml_files is case-sensitive for extensions."""
files = [
Path("/path/to/config.yaml"),
Path("/path/to/config.YAML"),
Path("/path/to/config.YML"),
Path("/path/to/config.Yaml"),
Path("/path/to/config.yml"),
]
result = util.filter_yaml_files(files)
# Should only match lowercase .yaml and .yml
assert len(result) == 2
# Check the actual suffixes to ensure case-sensitive filtering
result_suffixes = [p.suffix for p in result]
assert ".yaml" in result_suffixes
assert ".yml" in result_suffixes
# Verify the filtered files have the expected names
result_names = [p.name for p in result]
assert "config.yaml" in result_names
assert "config.yml" in result_names
# Ensure uppercase extensions are NOT included
assert "config.YAML" not in result_names
assert "config.YML" not in result_names
assert "config.Yaml" not in result_names
@pytest.mark.parametrize(
("input_str", "expected"),
[
# Empty string
("", "''"),
# Simple strings that don't need quoting
("hello", "hello"),
("test123", "test123"),
("file.txt", "file.txt"),
("/path/to/file", "/path/to/file"),
("user@host", "user@host"),
("value:123", "value:123"),
("item,list", "item,list"),
("path-with-dash", "path-with-dash"),
# Strings that need quoting
("hello world", "'hello world'"),
("test\ttab", "'test\ttab'"),
("line\nbreak", "'line\nbreak'"),
("semicolon;here", "'semicolon;here'"),
("pipe|symbol", "'pipe|symbol'"),
("redirect>file", "'redirect>file'"),
("redirect<file", "'redirect<file'"),
("background&", "'background&'"),
("dollar$sign", "'dollar$sign'"),
("backtick`cmd", "'backtick`cmd'"),
('double"quote', "'double\"quote'"),
("backslash\\path", "'backslash\\path'"),
("question?mark", "'question?mark'"),
("asterisk*wild", "'asterisk*wild'"),
("bracket[test]", "'bracket[test]'"),
("paren(test)", "'paren(test)'"),
("curly{brace}", "'curly{brace}'"),
# Single quotes in string (special escaping)
("it's", "'it'\"'\"'s'"),
("don't", "'don'\"'\"'t'"),
("'quoted'", "''\"'\"'quoted'\"'\"''"),
# Complex combinations
("test 'with' quotes", "'test '\"'\"'with'\"'\"' quotes'"),
("path/to/file's.txt", "'path/to/file'\"'\"'s.txt'"),
],
)
def test_shlex_quote(input_str: str, expected: str) -> None:
"""Test shlex_quote properly escapes shell arguments."""
assert util.shlex_quote(input_str) == expected
def test_shlex_quote_safe_characters() -> None:
"""Test that safe characters are not quoted."""
# These characters are considered safe and shouldn't be quoted
safe_chars = (
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@%+=:,./-_"
)
for char in safe_chars:
assert util.shlex_quote(char) == char
assert util.shlex_quote(f"test{char}test") == f"test{char}test"
def test_shlex_quote_unsafe_characters() -> None:
"""Test that unsafe characters trigger quoting."""
# These characters should trigger quoting
unsafe_chars = ' \t\n;|>&<$`"\\?*[](){}!#~^'
for char in unsafe_chars:
result = util.shlex_quote(f"test{char}test")
assert result.startswith("'")
assert result.endswith("'")
def test_shlex_quote_edge_cases() -> None:
"""Test edge cases for shlex_quote."""
# Multiple single quotes
assert util.shlex_quote("'''") == "''\"'\"''\"'\"''\"'\"''"
# Mixed quotes
assert util.shlex_quote('"\'"') == "'\"'\"'\"'\"'"
# Only whitespace
assert util.shlex_quote(" ") == "' '"
assert util.shlex_quote("\t") == "'\t'"
assert util.shlex_quote("\n") == "'\n'"
assert util.shlex_quote(" ") == "' '"
def test_get_picotool_path_found(tmp_path: Path) -> None:
"""Test picotool path derivation from cc_path."""
# Create the expected directory structure
packages_dir = tmp_path / "packages"
toolchain_dir = packages_dir / "toolchain-rp2040-earlephilhower" / "bin"
toolchain_dir.mkdir(parents=True)
gcc = toolchain_dir / "arm-none-eabi-gcc"
gcc.touch()
picotool_dir = packages_dir / "tool-picotool-rp2040-earlephilhower"
picotool_dir.mkdir(parents=True)
picotool = picotool_dir / "picotool"
picotool.touch()
result = util.get_picotool_path(str(gcc))
assert result == picotool
def test_get_picotool_path_not_found(tmp_path: Path) -> None:
"""Test picotool path returns None when not installed."""
packages_dir = tmp_path / "packages"
toolchain_dir = packages_dir / "toolchain-rp2040-earlephilhower" / "bin"
toolchain_dir.mkdir(parents=True)
gcc = toolchain_dir / "arm-none-eabi-gcc"
gcc.touch()
result = util.get_picotool_path(str(gcc))
assert result is None
def test_get_picotool_path_windows(tmp_path: Path) -> None:
"""Test picotool path uses .exe on Windows."""
packages_dir = tmp_path / "packages"
toolchain_dir = packages_dir / "toolchain-rp2040-earlephilhower" / "bin"
toolchain_dir.mkdir(parents=True)
gcc = toolchain_dir / "arm-none-eabi-gcc.exe"
gcc.touch()
picotool_dir = packages_dir / "tool-picotool-rp2040-earlephilhower"
picotool_dir.mkdir(parents=True)
picotool = picotool_dir / "picotool.exe"
picotool.touch()
with patch("esphome.util.sys.platform", "win32"):
result = util.get_picotool_path(str(gcc))
assert result == picotool
def test_detect_rp2040_bootsel_found() -> None:
"""Test BOOTSEL device detection when device is present."""
mock_result = MagicMock()
mock_result.stdout = b"Device Information\n type: RP2040\n"
with patch("esphome.util.subprocess.run", return_value=mock_result):
count = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert count == 1
def test_detect_rp2040_bootsel_multiple() -> None:
"""Test BOOTSEL detection with multiple devices."""
mock_result = MagicMock()
mock_result.stdout = b"type: RP2040\ntype: RP2350\n"
with patch("esphome.util.subprocess.run", return_value=mock_result):
count = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert count == 2
def test_detect_rp2040_bootsel_none() -> None:
"""Test BOOTSEL detection when no device found."""
mock_result = MagicMock()
mock_result.stdout = (
b"No accessible RP2040/RP2350 devices in BOOTSEL mode were found.\n"
)
with patch("esphome.util.subprocess.run", return_value=mock_result):
count = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert count == 0
def test_detect_rp2040_bootsel_oserror() -> None:
"""Test BOOTSEL detection handles OSError."""
with patch("esphome.util.subprocess.run", side_effect=OSError("not found")):
count = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert count == 0
def test_detect_rp2040_bootsel_timeout() -> None:
"""Test BOOTSEL detection handles timeout."""
with patch(
"esphome.util.subprocess.run",
side_effect=subprocess.TimeoutExpired("picotool", 10),
):
count = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert count == 0