mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 15:10:51 +00:00
915 lines
30 KiB
Python
915 lines
30 KiB
Python
import hashlib
|
|
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,
|
|
_node_key,
|
|
_normalize_dependencies,
|
|
_parse_library_json,
|
|
_parse_library_properties,
|
|
_resolve_registry_version,
|
|
_split_list_by_condition,
|
|
generate_cmakelists_txt,
|
|
generate_idf_component_yml,
|
|
generate_idf_components,
|
|
)
|
|
|
|
|
|
@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_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_node_key_git_with_ref():
|
|
key, is_git, locator = _node_key(
|
|
"name", None, "https://github.com/foo/bar.git#v1.2.3"
|
|
)
|
|
assert key == "foo/bar"
|
|
assert is_git is True
|
|
assert locator == ("https://github.com/foo/bar.git", "v1.2.3")
|
|
|
|
|
|
def test_node_key_git_branch_ref():
|
|
key, is_git, locator = _node_key(
|
|
"name", None, "https://github.com/foo/bar.git#some-branch"
|
|
)
|
|
assert (key, is_git, locator[1]) == ("foo/bar", True, "some-branch")
|
|
|
|
|
|
def test_node_key_git_no_ref():
|
|
_key, is_git, locator = _node_key("name", None, "https://github.com/foo/bar.git")
|
|
assert is_git is True
|
|
assert locator == ("https://github.com/foo/bar.git", None)
|
|
|
|
|
|
def test_node_key_registry_owner_name():
|
|
key, is_git, locator = _node_key("foo/bar", "^1.0.0", None)
|
|
assert (key, is_git, locator) == ("foo/bar", False, ("foo", "bar"))
|
|
|
|
|
|
def test_node_key_registry_bare_name():
|
|
key, is_git, locator = _node_key("bar", "1.0", None)
|
|
assert (key, is_git, locator) == ("bar", False, (None, "bar"))
|
|
|
|
|
|
def test_normalize_dependencies_none():
|
|
assert _normalize_dependencies(None) == []
|
|
|
|
|
|
def test_normalize_dependencies_list_form():
|
|
deps = [{"name": "foo", "version": "1.0"}]
|
|
assert _normalize_dependencies(deps) == [{"name": "foo", "version": "1.0"}]
|
|
|
|
|
|
def test_normalize_dependencies_dict_form():
|
|
out = _normalize_dependencies({"nanopb/Nanopb": "^0.4.91", "BareName": "1.2.3"})
|
|
assert {"name": "Nanopb", "owner": "nanopb", "version": "^0.4.91"} in out
|
|
assert {"name": "BareName", "owner": None, "version": "1.2.3"} in out
|
|
|
|
|
|
def test_normalize_dependencies_dict_form_nested_spec():
|
|
out = _normalize_dependencies(
|
|
{"nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"}}
|
|
)
|
|
assert out == [
|
|
{
|
|
"name": "Nanopb",
|
|
"owner": "nanopb",
|
|
"version": "^0.4.91",
|
|
"platforms": "espidf",
|
|
}
|
|
]
|
|
|
|
|
|
def _patch_registry(monkeypatch, versions):
|
|
"""Patch the registry client to serve a canned version list (no network).
|
|
|
|
Only ``fetch_registry_package`` is faked; the real
|
|
``get_compatible_registry_versions`` / ``pick_best_registry_version`` run on
|
|
the canned data so the intersection logic is exercised for real.
|
|
"""
|
|
registry = esphome.espidf.component._make_registry_client()
|
|
monkeypatch.setattr(
|
|
registry,
|
|
"fetch_registry_package",
|
|
lambda spec: {
|
|
"owner": {"username": spec.owner or "owner"},
|
|
"name": spec.name,
|
|
"versions": [
|
|
{"name": v, "files": [{"download_url": f"http://x/{v}.tar.gz"}]}
|
|
for v in versions
|
|
],
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
esphome.espidf.component, "_make_registry_client", lambda: registry
|
|
)
|
|
|
|
|
|
def test_resolve_registry_version_intersects_constraints(monkeypatch):
|
|
_patch_registry(monkeypatch, ["1.10018.1", "1.10021.0", "1.10021.1"])
|
|
owner, name, version, url = _resolve_registry_version(
|
|
"esphome", "libsodium", {"==1.10021.0", "^1.10018.1"}
|
|
)
|
|
assert (owner, name, version) == ("esphome", "libsodium", "1.10021.0")
|
|
assert url == "http://x/1.10021.0.tar.gz"
|
|
|
|
|
|
def test_resolve_registry_version_picks_highest_satisfying(monkeypatch):
|
|
_patch_registry(monkeypatch, ["1.0.0", "1.5.0", "2.0.0"])
|
|
_owner, _name, version, _url = _resolve_registry_version("o", "p", {"^1.0.0"})
|
|
assert version == "1.5.0"
|
|
|
|
|
|
def test_resolve_registry_version_conflict_raises(monkeypatch):
|
|
_patch_registry(monkeypatch, ["1.0.0", "2.0.0"])
|
|
with pytest.raises(RuntimeError, match="satisfies all requirements"):
|
|
_resolve_registry_version("o", "p", {"==1.0.0", "==2.0.0"})
|
|
|
|
|
|
def test_generate_idf_components_dedupes_shared_dependency(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
esp32_idf_core: None,
|
|
) -> None:
|
|
# A and B both depend on shared C under different version specs. The batch
|
|
# must resolve C once with BOTH requirements collected, wire a single C
|
|
# instance into both, and regenerate (overwrite) each library's build files.
|
|
manifests = {
|
|
"esphome/A": {
|
|
"name": "A",
|
|
"dependencies": [
|
|
{"owner": "esphome", "name": "C", "version": "==1.10021.0"}
|
|
],
|
|
},
|
|
"esphome/B": {
|
|
"name": "B",
|
|
"dependencies": [
|
|
{"owner": "esphome", "name": "C", "version": "^1.10018.1"}
|
|
],
|
|
},
|
|
"esphome/C": {"name": "C"},
|
|
}
|
|
|
|
def fake_download(self, force=False, salt=""):
|
|
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
|
|
(self.path / "src").mkdir(parents=True, exist_ok=True)
|
|
(self.path / "src" / "x.c").write_text("int x;")
|
|
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
|
|
(self.path / "CMakeLists.txt").write_text("# TRIPWIRE\n")
|
|
|
|
monkeypatch.setattr(IDFComponent, "download", fake_download)
|
|
|
|
captured: dict[str, set[str]] = {}
|
|
resolve_calls: list[str] = []
|
|
|
|
def fake_resolve(owner, pkgname, requirements):
|
|
resolve_calls.append(pkgname)
|
|
captured[f"{owner}/{pkgname}"] = set(requirements)
|
|
version = "1.10021.0" if pkgname == "C" else "1.0.0"
|
|
return owner, pkgname, version, f"http://x/{pkgname}.tar.gz"
|
|
|
|
monkeypatch.setattr(
|
|
esphome.espidf.component, "_resolve_registry_version", fake_resolve
|
|
)
|
|
|
|
top = generate_idf_components(
|
|
[Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)]
|
|
)
|
|
|
|
# C resolved once (not once per consumer) with BOTH requirements gathered.
|
|
assert captured["esphome/C"] == {"==1.10021.0", "^1.10018.1"}
|
|
assert resolve_calls.count("C") == 1
|
|
# Top-level components returned in request order.
|
|
assert [c.name for c in top] == ["esphome/A", "esphome/B"]
|
|
# A and B reference the SAME single C instance (deduped).
|
|
a_dep = top[0].dependencies[0]
|
|
b_dep = top[1].dependencies[0]
|
|
assert a_dep.name == "esphome/C"
|
|
assert a_dep is b_dep
|
|
# The bundled CMakeLists was overwritten with generated content.
|
|
generated = (a_dep.path / "CMakeLists.txt").read_text()
|
|
assert "TRIPWIRE" not in generated
|
|
assert "idf_component_register" in generated
|
|
|
|
|
|
def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
esp32_idf_core: None,
|
|
) -> None:
|
|
# lib_ignore must drop B at the top level and C when it is discovered as a
|
|
# dependency of A during the graph walk -- neither may be resolved,
|
|
# downloaded, or wired into a manifest. Matching is by lowercase short name.
|
|
manifests = {
|
|
"esphome/A": {
|
|
"name": "A",
|
|
"dependencies": [
|
|
{"owner": "esphome", "name": "C", "version": "==1.10021.0"}
|
|
],
|
|
},
|
|
"esphome/B": {"name": "B"},
|
|
}
|
|
|
|
download_salts: list[str] = []
|
|
|
|
def fake_download(self, force=False, salt=""):
|
|
download_salts.append(salt)
|
|
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
|
|
(self.path / "src").mkdir(parents=True, exist_ok=True)
|
|
(self.path / "src" / "x.c").write_text("int x;")
|
|
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
|
|
|
|
monkeypatch.setattr(IDFComponent, "download", fake_download)
|
|
|
|
resolve_calls: list[str] = []
|
|
|
|
def fake_resolve(owner, pkgname, requirements):
|
|
resolve_calls.append(pkgname)
|
|
return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz"
|
|
|
|
monkeypatch.setattr(
|
|
esphome.espidf.component, "_resolve_registry_version", fake_resolve
|
|
)
|
|
# lib_ignore is read from CORE.platformio_options (stored there by
|
|
# _add_platformio_options); matched by lowercase short name.
|
|
monkeypatch.setattr(CORE, "platformio_options", {"lib_ignore": ["B", "esphome/C"]})
|
|
|
|
top = generate_idf_components(
|
|
[Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)]
|
|
)
|
|
|
|
assert [c.name for c in top] == ["esphome/A"]
|
|
# Ignored libraries were never resolved (and therefore never downloaded).
|
|
assert resolve_calls == ["A"]
|
|
# The ignored dependency is not wired into A's manifest.
|
|
assert top[0].dependencies == []
|
|
# lib_ignore changes the generated wiring, so the cache path is salted to
|
|
# keep this conversion separate from ones with a different lib_ignore.
|
|
assert download_salts == [hashlib.sha256(b"b,c").hexdigest()[:8]]
|
|
|
|
|
|
def test_generate_idf_components_handles_dependency_cycle(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
esp32_idf_core: None,
|
|
) -> None:
|
|
# A -> B -> A. Must terminate (not recurse forever) and wire the cycle with
|
|
# a single instance per component.
|
|
manifests = {
|
|
"esphome/A": {
|
|
"name": "A",
|
|
"dependencies": [{"owner": "esphome", "name": "B", "version": "1.0.0"}],
|
|
},
|
|
"esphome/B": {
|
|
"name": "B",
|
|
"dependencies": [{"owner": "esphome", "name": "A", "version": "1.0.0"}],
|
|
},
|
|
}
|
|
|
|
def fake_download(self, force=False, salt=""):
|
|
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
|
|
(self.path / "src").mkdir(parents=True, exist_ok=True)
|
|
(self.path / "src" / "x.c").write_text("int x;")
|
|
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
|
|
|
|
monkeypatch.setattr(IDFComponent, "download", fake_download)
|
|
monkeypatch.setattr(
|
|
esphome.espidf.component,
|
|
"_resolve_registry_version",
|
|
lambda owner, pkgname, requirements: (
|
|
owner,
|
|
pkgname,
|
|
"1.0.0",
|
|
f"http://x/{pkgname}.tar.gz",
|
|
),
|
|
)
|
|
|
|
top = generate_idf_components([Library("esphome/A", "1.0.0", None)])
|
|
|
|
assert [c.name for c in top] == ["esphome/A"]
|
|
component_a = top[0]
|
|
component_b = component_a.dependencies[0]
|
|
assert component_b.name == "esphome/B"
|
|
# The cycle is wired back to the same A instance, not a duplicate.
|
|
assert component_b.dependencies[0] is component_a
|
|
|
|
|
|
def test_generate_idf_components_git_overrides_registry_warns(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
esp32_idf_core: None,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
# A pulls shared as a registry pin; B pulls the same component from a git
|
|
# source. The git source wins, but the dropped registry pin must be warned
|
|
# about (not silently discarded).
|
|
manifests = {
|
|
"esphome/A": {
|
|
"name": "A",
|
|
"dependencies": [
|
|
{"owner": "esphome", "name": "shared", "version": "==1.0.0"}
|
|
],
|
|
},
|
|
"esphome/B": {
|
|
"name": "B",
|
|
"dependencies": [
|
|
{
|
|
"owner": "esphome",
|
|
"name": "shared",
|
|
"version": "https://github.com/esphome/shared.git#main",
|
|
}
|
|
],
|
|
},
|
|
"esphome/shared": {"name": "shared"},
|
|
}
|
|
|
|
def fake_download(self, force=False, salt=""):
|
|
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
|
|
(self.path / "src").mkdir(parents=True, exist_ok=True)
|
|
(self.path / "src" / "x.c").write_text("int x;")
|
|
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
|
|
|
|
monkeypatch.setattr(IDFComponent, "download", fake_download)
|
|
monkeypatch.setattr(
|
|
esphome.espidf.component,
|
|
"_resolve_registry_version",
|
|
lambda owner, pkgname, requirements: (
|
|
owner,
|
|
pkgname,
|
|
"1.0.0",
|
|
f"http://x/{pkgname}.tar.gz",
|
|
),
|
|
)
|
|
|
|
top = generate_idf_components(
|
|
[Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)]
|
|
)
|
|
|
|
# shared resolved from the git source (version "*"), not the registry pin.
|
|
shared = top[0].dependencies[0]
|
|
assert shared.name == "esphome/shared"
|
|
assert isinstance(shared.source, GitSource)
|
|
assert "using the git source" in caplog.text
|
|
assert "==1.0.0" in caplog.text
|
|
|
|
|
|
def test_generate_idf_components_missing_manifest_raises(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
esp32_idf_core: None,
|
|
) -> None:
|
|
# A library with neither library.json nor library.properties is invalid;
|
|
# fail loudly rather than silently generating build files for it.
|
|
def fake_download(self, force=False, salt=""):
|
|
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
|
|
(self.path / "src").mkdir(parents=True, exist_ok=True)
|
|
# no library.json / library.properties written
|
|
|
|
monkeypatch.setattr(IDFComponent, "download", fake_download)
|
|
monkeypatch.setattr(
|
|
esphome.espidf.component,
|
|
"_resolve_registry_version",
|
|
lambda owner, pkgname, requirements: (
|
|
owner,
|
|
pkgname,
|
|
"1.0.0",
|
|
f"http://x/{pkgname}.tar.gz",
|
|
),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="missing library.json"):
|
|
generate_idf_components([Library("esphome/A", "1.0.0", None)])
|
|
|
|
|
|
def test_generate_idf_components_warns_on_noncanonical_duplicate(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
esp32_idf_core: None,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
# A references "shared" (bare) and B references "owner/shared"; both resolve
|
|
# to the same canonical name but as distinct graph nodes, so they aren't
|
|
# deduplicated -- warn about it.
|
|
manifests = {
|
|
"esphome/A": {
|
|
"name": "A",
|
|
"dependencies": [{"name": "shared", "version": "1.0.0"}],
|
|
},
|
|
"esphome/B": {
|
|
"name": "B",
|
|
"dependencies": [{"owner": "owner", "name": "shared", "version": "1.0.0"}],
|
|
},
|
|
"owner/shared": {"name": "shared"},
|
|
}
|
|
|
|
def fake_download(self, force=False, salt=""):
|
|
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
|
|
(self.path / "src").mkdir(parents=True, exist_ok=True)
|
|
(self.path / "src" / "x.c").write_text("int x;")
|
|
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
|
|
|
|
monkeypatch.setattr(IDFComponent, "download", fake_download)
|
|
# Bare "shared" and "owner/shared" both resolve to canonical owner/shared.
|
|
monkeypatch.setattr(
|
|
esphome.espidf.component,
|
|
"_resolve_registry_version",
|
|
lambda owner, pkgname, requirements: (
|
|
owner or "owner",
|
|
pkgname,
|
|
"1.0.0",
|
|
f"http://x/{pkgname}.tar.gz",
|
|
),
|
|
)
|
|
|
|
generate_idf_components(
|
|
[Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)]
|
|
)
|
|
|
|
assert "referenced under multiple names" in caplog.text
|
|
|
|
|
|
def test_generate_idf_components_incompatible_top_level_raises(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
esp32_idf_core: None,
|
|
) -> None:
|
|
# A top-level library that isn't ESP-IDF/esp32 compatible must fail fast,
|
|
# not be silently dropped.
|
|
def fake_download(self, force=False, salt=""):
|
|
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
|
|
(self.path / "src").mkdir(parents=True, exist_ok=True)
|
|
(self.path / "library.json").write_text(
|
|
json.dumps({"name": "A", "platforms": ["espressif8266"]})
|
|
)
|
|
|
|
monkeypatch.setattr(IDFComponent, "download", fake_download)
|
|
monkeypatch.setattr(
|
|
esphome.espidf.component,
|
|
"_resolve_registry_version",
|
|
lambda owner, pkgname, requirements: (
|
|
owner,
|
|
pkgname,
|
|
"1.0.0",
|
|
f"http://x/{pkgname}.tar.gz",
|
|
),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="not compatible with ESP-IDF"):
|
|
generate_idf_components([Library("esphome/A", "1.0.0", None)])
|
|
|
|
|
|
def test_generate_idf_components_incompatible_dependency_skipped(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
esp32_idf_core: None,
|
|
) -> None:
|
|
# An incompatible *transitive* dependency is skipped (not fatal): A is fine,
|
|
# its esp8266-only dep B is dropped and not wired.
|
|
manifests = {
|
|
"esphome/A": {
|
|
"name": "A",
|
|
"dependencies": [{"owner": "esphome", "name": "B", "version": "1.0.0"}],
|
|
},
|
|
"esphome/B": {"name": "B", "platforms": ["espressif8266"]},
|
|
}
|
|
|
|
def fake_download(self, force=False, salt=""):
|
|
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
|
|
(self.path / "src").mkdir(parents=True, exist_ok=True)
|
|
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
|
|
|
|
monkeypatch.setattr(IDFComponent, "download", fake_download)
|
|
monkeypatch.setattr(
|
|
esphome.espidf.component,
|
|
"_resolve_registry_version",
|
|
lambda owner, pkgname, requirements: (
|
|
owner,
|
|
pkgname,
|
|
"1.0.0",
|
|
f"http://x/{pkgname}.tar.gz",
|
|
),
|
|
)
|
|
|
|
top = generate_idf_components([Library("esphome/A", "1.0.0", None)])
|
|
|
|
assert [c.name for c in top] == ["esphome/A"]
|
|
# The incompatible dependency was dropped, not wired in.
|
|
assert top[0].dependencies == []
|
|
|
|
|
|
def test_url_source_salt_changes_cache_path(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""The salt is mixed into the URL hash so salted conversions get their own
|
|
cache tree. Pre-created extraction markers keep this network-free."""
|
|
monkeypatch.setattr(CORE, "config_path", tmp_path / "test.yaml")
|
|
url = "http://example.com/lib.tar.gz"
|
|
base = tmp_path / ".esphome" / "pio_components"
|
|
expected = {}
|
|
for salt in ("", "abcd1234"):
|
|
digest = hashlib.sha256((url + salt).encode()).hexdigest()[:8]
|
|
expected[salt] = base / digest / "lib"
|
|
expected[salt].mkdir(parents=True)
|
|
(expected[salt] / ".esphome_extracted").touch()
|
|
|
|
source = URLSource(url)
|
|
assert source.download("lib") == expected[""]
|
|
assert source.download("lib", salt="abcd1234") == expected["abcd1234"]
|
|
|
|
|
|
def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The salt becomes a subdirectory of the git clone domain."""
|
|
domains: list[str] = []
|
|
|
|
def fake_clone_or_update(**kwargs):
|
|
domains.append(kwargs["domain"])
|
|
return Path("/cloned"), None
|
|
|
|
monkeypatch.setattr(
|
|
esphome.espidf.component.git, "clone_or_update", fake_clone_or_update
|
|
)
|
|
|
|
source = GitSource("https://github.com/esphome/noise-c.git", "v1.0")
|
|
source.download("noise-c")
|
|
source.download("noise-c", salt="abcd1234")
|
|
assert domains == ["pio_components", "pio_components/abcd1234"]
|
|
|
|
|
|
def test_idf_component_download_passes_salt() -> None:
|
|
"""IDFComponent.download forwards the sanitized name and salt to the
|
|
source and records the returned path."""
|
|
source = MagicMock()
|
|
source.download.return_value = Path("/converted/owner/name")
|
|
|
|
c = IDFComponent("owner/name", "1.0", source=source)
|
|
c.download(force=True, salt="abcd1234")
|
|
|
|
source.download.assert_called_once_with("owner/name", force=True, salt="abcd1234")
|
|
assert c.path == Path("/converted/owner/name")
|