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// 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", } ]