Files
esphome/tests/unit_tests/test_espidf_component.py

623 lines
19 KiB
Python

import json
import os
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from esphome.const import (
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
Framework,
Platform,
)
from esphome.core import CORE, Library
import esphome.espidf.component
from esphome.espidf.component import (
GitSource,
IDFComponent,
InvalidIDFComponent,
URLSource,
_check_library_data,
_collect_filtered_files,
_convert_library_to_component,
_parse_library_json,
_parse_library_properties,
_process_dependencies,
_split_list_by_condition,
generate_cmakelists_txt,
generate_idf_component_yml,
)
@pytest.fixture(name="tmp_component")
def fixture_tmp_component(tmp_path):
c = IDFComponent("owner/name", "1.0.0", source=MagicMock())
c.path = tmp_path
return c
@pytest.fixture(name="esp32_idf_core")
def fixture_esp32_idf_core():
CORE.data[KEY_CORE] = {}
CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = str(Platform.ESP32)
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = str(Framework.ESP_IDF)
def test_idf_component_str():
c = IDFComponent("foo/bar", "1.0", source=URLSource("http://dummy.com"))
assert str(c) == "foo/bar@1.0=http://dummy.com"
def test_idf_component_sanitized_name():
c = IDFComponent("foo/bar bar-bar", "1.0", source=URLSource("http://dummy.com"))
assert c.get_sanitized_name() == "foo/bar_bar-bar"
def test_idf_component_require_name():
c = IDFComponent("foo/bar", "1.0", source=URLSource("http://dummy.com"))
assert c.get_require_name() == "foo__bar"
def test_collect_filtered_files_basic(tmp_path):
f1 = tmp_path / "a.c"
f2 = tmp_path / "b" / "b.cpp"
f1.write_text("int a;")
f2.parent.mkdir(parents=True)
f2.write_text("int b;")
result = _collect_filtered_files(tmp_path, ["+<*>"])
assert str(f1) in result
assert str(f2) in result
def test_collect_filtered_files_exclude(tmp_path):
f1 = tmp_path / "a.c"
f2 = tmp_path / "b.cpp"
f1.write_text("int a;")
f2.write_text("int b;")
result = _collect_filtered_files(tmp_path, ["+<*> -<*.cpp>"])
assert str(f1) in result
assert str(f2) not in result
def test_split_list_by_condition():
items = ["-Iinclude", "-Llib", "-Wall"]
matched, rest = _split_list_by_condition(
items, lambda x: x[2:] if x.startswith("-I") else None
)
assert matched == ["include"]
assert "-Llib" in rest
assert "-Wall" in rest
def test_generate_cmakelists_txt_basic(tmp_component):
src_dir = tmp_component.path / "src"
src_dir.mkdir()
f = src_dir / "main.c"
f.write_text("int main() {}")
tmp_component.data = {}
content = generate_cmakelists_txt(tmp_component)
assert "idf_component_register" in content
assert "main.c" in content
def test_generate_cmakelists_txt_with_flags(tmp_component, tmp_path):
src_dir = tmp_component.path / "src"
src_dir.mkdir()
(src_dir / "main.c").write_text("int main() {}")
dep = IDFComponent("dep", "1.0", source=URLSource("http://dummy.com"))
dep.path = tmp_path / "dep"
tmp_component.dependencies = [dep]
tmp_component.data = {
"build": {"flags": ["-Iinclude", "-Llib", "-lmylib", "-Wall", "-DTEST"]}
}
content = generate_cmakelists_txt(tmp_component)
sep = "\\\\" if os.name == "nt" else "/"
assert (
content
== f"""idf_component_register(
SRCS "src{sep}main.c"
INCLUDE_DIRS "src"
REQUIRES dep ${{ESPHOME_PROJECT_MANAGED_COMPONENTS}} ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
)
target_compile_options(${{COMPONENT_LIB}} PUBLIC
"-DTEST"
)
target_compile_options(${{COMPONENT_LIB}} PRIVATE
"-Wall"
)
target_link_directories(${{COMPONENT_LIB}} INTERFACE
"lib"
)
target_link_libraries(${{COMPONENT_LIB}} INTERFACE
"mylib"
)
"""
)
def test_generate_cmakelists_txt_references_project_managed_components_variable(
tmp_component: IDFComponent,
) -> None:
# The CMakeLists is cached under pio_components/<hash>/ and shared
# across projects, so the project-managed REQUIRES list is exposed via
# a CMake variable expanded at configure time rather than baked here.
src_dir = tmp_component.path / "src"
src_dir.mkdir()
(src_dir / "main.c").write_text("int main() {}")
tmp_component.data = {}
content = generate_cmakelists_txt(tmp_component)
assert "${ESPHOME_PROJECT_MANAGED_COMPONENTS}" in content
def test_generate_idf_component_overwrites_bundled_files(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
esp32_idf_core: None,
) -> None:
# A library that ships its own CMakeLists.txt + idf_component.yml must
# have both replaced by ESPHome's generated content. Library authors'
# bundled IDF metadata is frequently broken (bogus REQUIRES, hard-coded
# frameworks), so we always regenerate from library.json.
from esphome.espidf.component import _generate_idf_component
(tmp_path / "src").mkdir()
(tmp_path / "src" / "main.cpp").write_text("// dummy\n")
(tmp_path / "library.json").write_text(json.dumps({"name": "tripwire-lib"}))
(tmp_path / "CMakeLists.txt").write_text("# TRIPWIRE_BUNDLED_CMAKELISTS\n")
(tmp_path / "idf_component.yml").write_text("# TRIPWIRE_BUNDLED_MANIFEST\n")
fake_component = IDFComponent(
"owner/tripwire-lib", "1.0.0", source=URLSource("http://dummy")
)
fake_component.path = tmp_path
monkeypatch.setattr(
esphome.espidf.component,
"_convert_library_to_component",
lambda _lib: fake_component,
)
monkeypatch.setattr(fake_component, "download", lambda force=False: None)
_generate_idf_component(Library("owner/tripwire-lib", "1.0.0", None))
cml = (tmp_path / "CMakeLists.txt").read_text()
manifest = (tmp_path / "idf_component.yml").read_text()
assert "TRIPWIRE_BUNDLED_CMAKELISTS" not in cml
assert "TRIPWIRE_BUNDLED_MANIFEST" not in manifest
assert "idf_component_register" in cml
def test_generate_idf_component_yml_basic(tmp_component):
tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}}
result = generate_idf_component_yml(tmp_component)
assert result == "description: test\nrepository: http://aaa\n"
def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path):
dep = IDFComponent("dep", "1.0", source=URLSource("http://dummy.com"))
dep.path = tmp_path / "dep"
tmp_component.dependencies = [dep]
tmp_component.data = {}
result = generate_idf_component_yml(tmp_component)
assert (
result
== f"""dependencies:
dep:
override_path: {dep.path}
"""
)
def test_generate_idf_component_yml_missing_path_raises(tmp_component):
# A dep without a path is a contract violation — every dep is expected
# to have been downloaded before YAML generation. Raise loudly.
dep = IDFComponent("foo/bar", "1.0", source=None)
tmp_component.dependencies = [dep]
tmp_component.data = {}
with pytest.raises(RuntimeError):
generate_idf_component_yml(tmp_component)
def test_check_library_data_valid(esp32_idf_core):
_check_library_data({"platforms": "*", "frameworks": "*"})
def test_check_library_data_valid2(esp32_idf_core):
_check_library_data({"platforms": "*"})
def test_check_library_data_valid3(esp32_idf_core):
_check_library_data({})
def test_check_library_data_valid4(esp32_idf_core):
_check_library_data({"platforms": "espressif32", "frameworks": "*"})
def test_check_library_data_valid5(esp32_idf_core):
_check_library_data({"platforms": "*", "frameworks": "espidf"})
def test_check_library_data_invalid_platform(esp32_idf_core):
with pytest.raises(InvalidIDFComponent):
_check_library_data({"platforms": ["other"], "frameworks": "*"})
def test_check_library_data_invalid_framework(
esp32_idf_core: None, caplog: pytest.LogCaptureFixture
) -> None:
# Framework mismatch is a warning, not a hard skip: the library is still
# included so that PIO manifests that only list "arduino" (but actually
# compile under IDF) can be used without forking them.
_check_library_data({"name": "lib", "platforms": "*", "frameworks": ["other"]})
assert "do not include 'espidf'" in caplog.text
def test_extra_script_captures_libpath_libs_and_defines(tmp_path):
from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script
(tmp_path / "src" / "esp32").mkdir(parents=True)
script = tmp_path / "extra_script.py"
script.write_text(
"Import('env')\n"
"mcu = env.get('BOARD_MCU')\n"
"env.Append(\n"
" LIBPATH=[join('src', mcu)],\n"
" LIBS=['algobsec'],\n"
" CPPDEFINES=['FOO', ('BAR', '1')],\n"
" LINKFLAGS=['-Wl,--gc-sections'],\n"
")\n"
)
# The script uses bare ``join`` (PIO's extra-scripts run inside SCons
# where this is in scope). Inject it via the script header so the
# shim's exec namespace can resolve it.
script.write_text("from os.path import join\n" + script.read_text())
result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32")
assert result.libpath == [str(Path("src") / "esp32")]
assert result.libs == ["algobsec"]
assert ("BAR", "1") in result.cppdefines
assert "FOO" in result.cppdefines
assert result.linkflags == ["-Wl,--gc-sections"]
flags = captured_as_build_flags(result, library_dir=tmp_path)
sep = os.sep
assert f"-Lsrc{sep}esp32" in flags
assert "-lalgobsec" in flags
assert "-DFOO" in flags
assert "-DBAR=1" in flags
assert "-Wl,--gc-sections" in flags
def test_extra_script_libpath_relative_resolves_against_library_dir(
tmp_path, monkeypatch
):
"""Relative LIBPATH entries must resolve against ``library_dir``, not the
caller's CWD (the shim restores CWD before ``captured_as_build_flags``
runs)."""
from esphome.espidf.extra_script import ExtraScriptResult, captured_as_build_flags
(tmp_path / "lib" / "esp32").mkdir(parents=True)
elsewhere = tmp_path.parent / "not_the_library_dir"
elsewhere.mkdir(exist_ok=True)
monkeypatch.chdir(elsewhere)
result = ExtraScriptResult(libpath=["lib/esp32"])
flags = captured_as_build_flags(result, library_dir=tmp_path)
sep = os.sep
assert flags == [f"-Llib{sep}esp32"]
def test_extra_script_libpath_absolute_outside_library_dir(tmp_path):
from esphome.espidf.extra_script import ExtraScriptResult, captured_as_build_flags
outside = tmp_path.parent / "system_lib"
outside.mkdir(exist_ok=True)
result = ExtraScriptResult(libpath=[str(outside)])
flags = captured_as_build_flags(result, library_dir=tmp_path)
assert flags == [f"-L{outside.resolve()}"]
def test_extra_script_failure_returns_empty_result(tmp_path, caplog):
from esphome.espidf.extra_script import run_extra_script
script = tmp_path / "broken.py"
script.write_text("raise RuntimeError('boom')\n")
with caplog.at_level("WARNING"):
result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32")
assert result.libpath == []
assert result.libs == []
assert "broken.py" in caplog.text
def test_apply_extra_script_path_traversal_is_rejected(tmp_path):
from esphome.espidf.component import _apply_extra_script
library_dir = tmp_path / "lib"
library_dir.mkdir()
outside = tmp_path / "evil.py"
outside.write_text("env.Append(LIBS=['pwned'])\n")
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
c.path = library_dir
c.data = {"build": {"extraScript": "../evil.py"}}
_apply_extra_script(c)
# Nothing was folded into flags: the traversal was rejected before
# the script could run.
assert "flags" not in c.data["build"]
def test_apply_extra_script_merges_into_existing_flags(tmp_path, monkeypatch):
from esphome.components import esp32 as esp32_module
monkeypatch.setattr(esp32_module, "get_esp32_variant", lambda: "ESP32")
from esphome.espidf.component import _apply_extra_script
(tmp_path / "src").mkdir()
script = tmp_path / "extra.py"
script.write_text("env.Append(LIBS=['algobsec'])\n")
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
c.path = tmp_path
c.data = {"build": {"extraScript": "extra.py", "flags": ["-DEXISTING"]}}
_apply_extra_script(c)
assert "-DEXISTING" in c.data["build"]["flags"]
assert "-lalgobsec" in c.data["build"]["flags"]
def test_parse_library_json(tmp_path):
f = tmp_path / "library.json"
f.write_text(json.dumps({"name": "test"}))
result = _parse_library_json(f)
assert result["name"] == "test"
def test_parse_library_properties(tmp_path):
f = tmp_path / "library.properties"
f.write_text(
"""
name=Test
version=1.0
# description=ABCD
empty=
"""
)
result = _parse_library_properties(f)
assert result["name"] == "Test"
assert result["version"] == "1.0"
assert "empty" not in result
def test_convert_library_with_repository():
lib = Library("name", None, "https://github.com/foo/bar.git#v1.2.3")
result = _convert_library_to_component(lib)
assert result.name == "foo/bar"
assert result.version == "*"
assert isinstance(result.source, GitSource)
assert result.source.ref == "v1.2.3"
def test_convert_library_with_branch_ref():
lib = Library("name", None, "https://github.com/foo/bar.git#some-branch")
result = _convert_library_to_component(lib)
assert result.name == "foo/bar"
assert result.version == "*"
assert isinstance(result.source, GitSource)
assert result.source.ref == "some-branch"
def test_convert_library_missing_ref_uses_default_branch():
"""A bare URL with no #ref clones the remote's default branch.
Matches PIO's lib_deps behavior and external_components handling --
git.clone_or_update with ref=None leaves the depth-1 clone on
whatever branch the remote HEAD points at.
"""
lib = Library("name", None, "https://github.com/foo/bar.git")
result = _convert_library_to_component(lib)
assert result.name == "foo/bar"
assert result.version == "*"
assert isinstance(result.source, GitSource)
assert result.source.ref is None
def test_convert_library_registry(monkeypatch):
lib = Library("foo/bar", "^1.0.0", None)
monkeypatch.setattr(
esphome.espidf.component,
"_get_package_from_pio_registry",
lambda o, n, r: ("foo", "bar", "1.2.3", "http://example.com/pkg.zip"),
)
result = _convert_library_to_component(lib)
assert result.name == "foo/bar"
assert result.version == "1.2.3"
assert isinstance(result.source, URLSource)
def test_process_dependencies_adds_valid_dependency(tmp_component, monkeypatch):
tmp_component.data = {
"dependencies": [
{
"name": "foo",
"version": "1.0",
}
]
}
monkeypatch.setattr(
esphome.espidf.component,
"_generate_idf_component",
lambda lib: esphome.espidf.component.IDFComponent(
lib.name, lib.version, source=URLSource("http://dummy.com")
),
)
monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None)
_process_dependencies(tmp_component)
assert len(tmp_component.dependencies) == 1
def test_process_dependencies_skips_invalid(tmp_component):
tmp_component.data = {
"dependencies": [
{"name": "foo", "version": "1.0", "platforms": ["arduino"]},
{"invalid": "entry"},
]
}
_process_dependencies(tmp_component)
assert tmp_component.dependencies == []
def test_process_dependencies_dict_form(tmp_component, monkeypatch):
"""PIO library.json shorthand ``{"owner/Name": "version"}`` is honored.
Iterating a dict gives string keys, which would silently fail the
``"name" in dependency`` substring check. Normalize to list-of-dicts
first so the dict form (used by e.g. tesla-ble for its nanopb dep)
is treated the same as the verbose list form.
"""
captured: list[Library] = []
def fake_generate(library):
captured.append(library)
return IDFComponent(
library.name, library.version, source=URLSource("http://dummy.com")
)
tmp_component.data = {
"dependencies": {
"nanopb/Nanopb": "^0.4.91",
"BareName": "1.2.3",
}
}
monkeypatch.setattr(
esphome.espidf.component, "_generate_idf_component", fake_generate
)
monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None)
_process_dependencies(tmp_component)
assert len(tmp_component.dependencies) == 2
names = sorted(lib.name for lib in captured)
versions = sorted(lib.version for lib in captured)
assert names == ["BareName", "nanopb/Nanopb"]
assert versions == ["1.2.3", "^0.4.91"]
def test_process_dependencies_dict_form_with_url_value(tmp_component, monkeypatch):
"""A dict-value that's a URL gets routed to ``repository`` like the list form."""
captured: list[Library] = []
def fake_generate(library):
captured.append(library)
return IDFComponent(library.name, "*", source=URLSource("http://dummy.com"))
tmp_component.data = {
"dependencies": {
"foo/Bar": "https://github.com/foo/bar.git#main",
}
}
monkeypatch.setattr(
esphome.espidf.component, "_generate_idf_component", fake_generate
)
monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None)
_process_dependencies(tmp_component)
assert len(captured) == 1
assert captured[0].name == "foo/Bar"
assert captured[0].version is None
assert captured[0].repository == "https://github.com/foo/bar.git#main"
def test_process_dependencies_dict_form_with_nested_spec(tmp_component, monkeypatch):
"""A dict-value that's itself a dict is merged into the entry.
PIO's library.json allows ``{"owner/Name": {"version": "...", ...}}``
for entries that need fields beyond just a version (platforms,
frameworks, etc.). The extra fields flow into _check_library_data
via the entry merge.
"""
captured: list[Library] = []
checked: list[dict] = []
def fake_generate(library):
captured.append(library)
return IDFComponent(
library.name, library.version, source=URLSource("http://dummy.com")
)
tmp_component.data = {
"dependencies": {
"nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"},
}
}
monkeypatch.setattr(
esphome.espidf.component, "_generate_idf_component", fake_generate
)
monkeypatch.setattr(
esphome.espidf.component,
"_check_library_data",
checked.append,
)
_process_dependencies(tmp_component)
assert len(captured) == 1
assert captured[0].name == "nanopb/Nanopb"
assert captured[0].version == "^0.4.91"
# Extra spec fields reach _check_library_data so platform/framework
# gating still applies.
assert checked == [
{
"name": "Nanopb",
"owner": "nanopb",
"version": "^0.4.91",
"platforms": "espidf",
}
]