mirror of
https://github.com/esphome/esphome.git
synced 2026-10-03 17:55:57 +00:00
Merge remote-tracking branch 'origin/esp8266-arduino-toolchain' into esp8266-native-pch
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -35,22 +36,25 @@ def _reset_core(tmp_path: Path) -> None:
|
||||
}
|
||||
|
||||
|
||||
def _write_project_description(tmp_path: Path, components: dict[str, str]) -> None:
|
||||
def _write_project_description(
|
||||
tmp_path: Path, components: dict[str, str], idf_path: str = "/idf"
|
||||
) -> None:
|
||||
"""Stub a project_description.json with the given component_name -> dir map."""
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir(exist_ok=True)
|
||||
(build_dir / "project_description.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"idf_path": idf_path,
|
||||
"build_component_info": {
|
||||
name: {"dir": dir_} for name, dir_ in components.items()
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _render(minimal: bool = False) -> str:
|
||||
def _render(minimal: bool = False, builtin_components: list[str] | None = None) -> str:
|
||||
"""Render the top-level CMakeLists with the standard variant/name patches."""
|
||||
with (
|
||||
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
|
||||
@@ -58,7 +62,9 @@ def _render(minimal: bool = False) -> str:
|
||||
):
|
||||
from esphome.build_gen.espidf import get_project_cmakelists
|
||||
|
||||
return get_project_cmakelists(minimal=minimal)
|
||||
return get_project_cmakelists(
|
||||
minimal=minimal, builtin_components=builtin_components
|
||||
)
|
||||
|
||||
|
||||
def test_get_available_components_returns_none_without_build_path() -> None:
|
||||
@@ -77,8 +83,11 @@ def test_get_available_components_returns_none_without_project_description(
|
||||
assert get_available_components() is None
|
||||
|
||||
|
||||
def test_get_available_components_filters_src_managed_and_pio(tmp_path: Path) -> None:
|
||||
"""Built-ins are returned; src/, managed_components/, pio_components/ skipped."""
|
||||
def test_get_available_components_keeps_only_idf_tree_components(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Only components under idf_path/components are built-ins: src, managed,
|
||||
converted PIO libs and Arduino component_stubs are all left out."""
|
||||
_write_project_description(
|
||||
tmp_path,
|
||||
{
|
||||
@@ -86,6 +95,7 @@ def test_get_available_components_filters_src_managed_and_pio(tmp_path: Path) ->
|
||||
"esp_lcd": "/idf/components/esp_lcd",
|
||||
"espressif__arduino-esp32": f"{tmp_path}/managed_components/arduino",
|
||||
"JPEGDEC": f"{tmp_path}/pio_components/arduino/abc/bitbank2/JPEGDEC",
|
||||
"cbor": f"{tmp_path}/component_stubs/cbor",
|
||||
"freertos": "/idf/components/freertos",
|
||||
},
|
||||
)
|
||||
@@ -94,6 +104,75 @@ def test_get_available_components_filters_src_managed_and_pio(tmp_path: Path) ->
|
||||
assert sorted(get_available_components()) == ["esp_lcd", "freertos"]
|
||||
|
||||
|
||||
def test_codegen_and_configure_writes_render_the_same_cmakelists(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""write_project() at codegen time (no list) and the configure-time write
|
||||
(discovered list) must agree, or ninja re-runs cmake on every build."""
|
||||
_write_project_description(
|
||||
tmp_path,
|
||||
{
|
||||
"lwip": "/idf/components/lwip",
|
||||
"cbor": f"{tmp_path}/component_stubs/cbor",
|
||||
},
|
||||
)
|
||||
from esphome.build_gen.espidf import get_available_components
|
||||
|
||||
assert _render() == _render(builtin_components=get_available_components())
|
||||
assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS cbor" not in _render()
|
||||
|
||||
|
||||
def test_get_available_components_warns_when_nothing_is_under_idf_path(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
_write_project_description(tmp_path, {"cbor": f"{tmp_path}/component_stubs/cbor"})
|
||||
from esphome.build_gen.espidf import (
|
||||
get_available_components,
|
||||
has_discovered_components,
|
||||
)
|
||||
|
||||
assert get_available_components() == []
|
||||
assert "No ESP-IDF components found under" in caplog.text
|
||||
# An empty discovery must not count as configured, or it would be latched in.
|
||||
assert not has_discovered_components()
|
||||
|
||||
|
||||
def test_get_available_components_ignores_corrupt_or_unexpected_file(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir()
|
||||
from esphome.build_gen.espidf import (
|
||||
get_available_components,
|
||||
has_discovered_components,
|
||||
)
|
||||
|
||||
(build_dir / "project_description.json").write_text("{not json")
|
||||
assert get_available_components() is None
|
||||
assert not has_discovered_components()
|
||||
(build_dir / "project_description.json").write_text('{"build_component_info": {}}')
|
||||
with caplog.at_level(logging.DEBUG, logger="esphome.build_gen.espidf"):
|
||||
assert get_available_components() is None
|
||||
assert "Could not read" in caplog.text
|
||||
|
||||
|
||||
def test_has_discovered_components_after_configure(tmp_path: Path) -> None:
|
||||
_write_project_description(tmp_path, {"lwip": "/idf/components/lwip"})
|
||||
from esphome.build_gen.espidf import has_discovered_components
|
||||
|
||||
assert has_discovered_components()
|
||||
|
||||
|
||||
def test_get_project_cmakelists_uses_supplied_builtin_components() -> None:
|
||||
"""A cached list replaces project_description.json and is still filtered
|
||||
by EXCLUDE_COMPONENTS."""
|
||||
with patch.dict(CORE.cmake_args, {"EXCLUDE_COMPONENTS": "fatfs;unity"}):
|
||||
content = _render(builtin_components=["lwip", "fatfs", "esp_timer"])
|
||||
assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_timer APPEND" in content
|
||||
assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS lwip APPEND" in content
|
||||
assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS fatfs APPEND" not in content
|
||||
|
||||
|
||||
def test_get_project_cmakelists_minimal_omits_builtin_components_property(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
@@ -10,8 +10,10 @@ during the adoption flow and depend on the output's ``esphome.name``
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests as req
|
||||
import yaml as pyyaml
|
||||
|
||||
from esphome.components.dashboard_import import import_config
|
||||
@@ -201,3 +203,56 @@ def test_import_refuses_to_overwrite_existing_yaml(tmp_path: Path) -> None:
|
||||
)
|
||||
# Original content survives unchanged.
|
||||
assert yaml_path.read_text() == "# user's hand-edited config\n"
|
||||
|
||||
|
||||
def _full_config_kwargs(yaml_path: Path) -> dict:
|
||||
return {
|
||||
"path": str(yaml_path),
|
||||
"name": "kitchen",
|
||||
"friendly_name": None,
|
||||
"project_name": "acme.kitchen-light",
|
||||
"import_url": "github://acme/firmware/kitchen.yaml@main?full_config",
|
||||
}
|
||||
|
||||
|
||||
def test_full_config_import_fetches_and_writes_contents(tmp_path: Path) -> None:
|
||||
yaml_path = tmp_path / "kitchen.yaml"
|
||||
resp = MagicMock(text="esphome:\n name: orig\n")
|
||||
with patch(
|
||||
"esphome.components.dashboard_import.http_request", return_value=resp
|
||||
) as mock_req:
|
||||
import_config(**_full_config_kwargs(yaml_path))
|
||||
assert yaml_path.read_text() == "esphome:\n name: orig\n"
|
||||
assert mock_req.call_args[0][0] == "GET"
|
||||
|
||||
|
||||
def test_full_config_import_retries_transient_errors(tmp_path: Path) -> None:
|
||||
"""The fetch goes through the shared retry policy: a transient network
|
||||
error is retried instead of failing the adoption immediately."""
|
||||
yaml_path = tmp_path / "kitchen.yaml"
|
||||
resp = MagicMock(text="esphome:\n name: orig\n")
|
||||
with (
|
||||
patch(
|
||||
"esphome.components.dashboard_import.http_request",
|
||||
side_effect=[req.ConnectionError("reset"), resp],
|
||||
),
|
||||
patch("esphome.net_retry.time.sleep") as mock_sleep,
|
||||
):
|
||||
import_config(**_full_config_kwargs(yaml_path))
|
||||
assert yaml_path.exists()
|
||||
mock_sleep.assert_called_once_with(2)
|
||||
|
||||
|
||||
def test_full_config_import_wraps_permanent_errors_in_value_error(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""device-builder depends on the ValueError contract for fetch failures."""
|
||||
resp = MagicMock()
|
||||
resp.raise_for_status.side_effect = req.HTTPError(
|
||||
"404", response=MagicMock(status_code=404)
|
||||
)
|
||||
with (
|
||||
patch("esphome.components.dashboard_import.http_request", return_value=resp),
|
||||
pytest.raises(ValueError, match="Error while fetching"),
|
||||
):
|
||||
import_config(**_full_config_kwargs(tmp_path / "kitchen.yaml"))
|
||||
|
||||
@@ -371,10 +371,9 @@ def _fake_download_from_mirrors(
|
||||
) -> str:
|
||||
"""Stand-in for download_from_mirrors that creates path targets, since
|
||||
the framework code opens the downloaded tarball afterwards."""
|
||||
if isinstance(target, (str, os.PathLike)):
|
||||
path = Path(target)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.touch()
|
||||
path = Path(target)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.touch()
|
||||
return "https://example.com/idf.tar.xz"
|
||||
|
||||
|
||||
@@ -384,13 +383,15 @@ def espidf_mocks(setup_core: Path):
|
||||
# archive_extract_all is mocked, so pre-create the framework dir that the
|
||||
# extracted-marker touch writes into.
|
||||
_get_framework_path(_IDF_VERSION).mkdir(parents=True, exist_ok=True)
|
||||
# One mock covers the tarball (via framework_helpers.download_and_extract)
|
||||
# and the constraints file (espidf-bound download_from_mirrors), so call
|
||||
# counts and ordering assertions span the two.
|
||||
download = MagicMock(side_effect=_fake_download_from_mirrors)
|
||||
with (
|
||||
patch("esphome.espidf.framework.rmdir") as rmdir_mock,
|
||||
patch(
|
||||
"esphome.espidf.framework.download_from_mirrors",
|
||||
side_effect=_fake_download_from_mirrors,
|
||||
) as download,
|
||||
patch("esphome.espidf.framework.archive_extract_all") as extract,
|
||||
patch("esphome.framework_helpers.download_from_mirrors", download),
|
||||
patch("esphome.espidf.framework.download_from_mirrors", download),
|
||||
patch("esphome.framework_helpers.archive_extract_all") as extract,
|
||||
patch("esphome.espidf.framework.create_venv") as venv,
|
||||
patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok,
|
||||
patch(
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -309,6 +311,11 @@ def test_run_compile_restamps_cmakecache_after_discovery(setup_core: Path) -> No
|
||||
|
||||
with (
|
||||
patch.object(toolchain, "need_reconfigure", return_value=True),
|
||||
patch.object(toolchain, "load_cached_builtin_components", return_value=None),
|
||||
patch.object(toolchain, "save_cached_builtin_components"),
|
||||
patch(
|
||||
"esphome.build_gen.espidf.get_available_components", return_value=["lwip"]
|
||||
),
|
||||
patch("esphome.build_gen.espidf.write_project"),
|
||||
patch.object(toolchain, "run_reconfigure", return_value=0),
|
||||
patch.object(toolchain, "run_idf_py", return_value=0),
|
||||
@@ -329,6 +336,11 @@ def test_run_compile_discovery_without_cmakecache(setup_core: Path) -> None:
|
||||
|
||||
with (
|
||||
patch.object(toolchain, "need_reconfigure", return_value=True),
|
||||
patch.object(toolchain, "load_cached_builtin_components", return_value=None),
|
||||
patch.object(toolchain, "save_cached_builtin_components"),
|
||||
patch(
|
||||
"esphome.build_gen.espidf.get_available_components", return_value=["lwip"]
|
||||
),
|
||||
patch("esphome.build_gen.espidf.write_project"),
|
||||
patch.object(toolchain, "run_reconfigure", return_value=0),
|
||||
patch.object(toolchain, "run_idf_py", return_value=0),
|
||||
@@ -354,7 +366,7 @@ def test_run_compile_reconfigures_after_full_write_outside_testing_mode(
|
||||
calls: list[tuple] = []
|
||||
reconfigures = 0
|
||||
|
||||
def record_write(minimal: bool = False) -> None:
|
||||
def record_write(minimal: bool = False, builtin_components=None) -> None:
|
||||
calls.append(("write_project", minimal))
|
||||
|
||||
def record_reconfigure() -> int:
|
||||
@@ -365,6 +377,11 @@ def test_run_compile_reconfigures_after_full_write_outside_testing_mode(
|
||||
|
||||
with (
|
||||
patch.object(toolchain, "need_reconfigure", return_value=True),
|
||||
patch.object(toolchain, "load_cached_builtin_components", return_value=None),
|
||||
patch.object(toolchain, "save_cached_builtin_components"),
|
||||
patch(
|
||||
"esphome.build_gen.espidf.get_available_components", return_value=["lwip"]
|
||||
),
|
||||
patch("esphome.build_gen.espidf.write_project", side_effect=record_write),
|
||||
patch.object(toolchain, "run_reconfigure", side_effect=record_reconfigure),
|
||||
patch.object(toolchain, "run_idf_py", return_value=0) as mock_build,
|
||||
@@ -383,6 +400,229 @@ def test_run_compile_reconfigures_after_full_write_outside_testing_mode(
|
||||
assert cmakecache.stat().st_mtime == old
|
||||
|
||||
|
||||
def _record_compile_calls(
|
||||
cached: list[str] | None,
|
||||
saved: list[str] | None = None,
|
||||
reconfigure_rcs: tuple[int, ...] = (),
|
||||
cache_file: Path | None = None,
|
||||
) -> tuple[int, list[tuple]]:
|
||||
"""Run run_compile with a stubbed cache and return (rc, call log).
|
||||
|
||||
``reconfigure_rcs`` overrides the exit codes of the first reconfigures;
|
||||
later ones succeed.
|
||||
"""
|
||||
calls: list[tuple] = []
|
||||
rcs = iter(reconfigure_rcs)
|
||||
|
||||
def record_reconfigure() -> int:
|
||||
calls.append(("run_reconfigure",))
|
||||
return next(rcs, 0)
|
||||
|
||||
def record_write(minimal: bool = False, builtin_components=None) -> None:
|
||||
calls.append(("write_project", minimal, builtin_components))
|
||||
|
||||
def record_save(components: list[str]) -> None:
|
||||
calls.append(("save", components))
|
||||
|
||||
with (
|
||||
patch.object(toolchain, "need_reconfigure", return_value=True),
|
||||
patch.object(toolchain, "load_cached_builtin_components", return_value=cached),
|
||||
patch.object(
|
||||
toolchain, "save_cached_builtin_components", side_effect=record_save
|
||||
),
|
||||
patch("esphome.build_gen.espidf.get_available_components", return_value=saved),
|
||||
patch("esphome.build_gen.espidf.write_project", side_effect=record_write),
|
||||
patch.object(toolchain, "run_reconfigure", side_effect=record_reconfigure),
|
||||
patch.object(
|
||||
toolchain, "_builtin_component_cache_path", return_value=cache_file
|
||||
),
|
||||
patch.object(
|
||||
toolchain,
|
||||
"run_idf_py",
|
||||
side_effect=lambda *a, **kw: calls.append(("build",)) or 0,
|
||||
),
|
||||
patch.object(toolchain, "print_summary"),
|
||||
):
|
||||
rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
|
||||
return rc, calls
|
||||
|
||||
|
||||
def test_run_compile_poisoned_cache_is_dropped_and_rediscovered(
|
||||
setup_core: Path, tmp_path: Path
|
||||
) -> None:
|
||||
"""A cached list that fails the configure is deleted and discovery runs
|
||||
once more instead of every later build failing the same way."""
|
||||
_setup_build(setup_core)
|
||||
cache_file = tmp_path / "esp32-abc.json"
|
||||
cache_file.write_text("[]")
|
||||
rc, calls = _record_compile_calls(
|
||||
["stale"], saved=["lwip"], reconfigure_rcs=(1,), cache_file=cache_file
|
||||
)
|
||||
assert rc == 0
|
||||
assert not cache_file.exists()
|
||||
assert calls == [
|
||||
("write_project", False, ["stale"]),
|
||||
("run_reconfigure",),
|
||||
("write_project", True, None),
|
||||
("run_reconfigure",),
|
||||
("write_project", False, ["lwip"]),
|
||||
("run_reconfigure",),
|
||||
("save", ["lwip"]),
|
||||
("build",),
|
||||
]
|
||||
|
||||
|
||||
def test_run_compile_cache_miss_discovers_and_saves(setup_core: Path) -> None:
|
||||
"""Without a cached list the discovery configure runs, the discovered list
|
||||
feeds the full write and is cached only after that configure succeeds."""
|
||||
_setup_build(setup_core)
|
||||
rc, calls = _record_compile_calls(None, saved=["lwip"])
|
||||
assert rc == 0
|
||||
assert calls == [
|
||||
("write_project", True, None),
|
||||
("run_reconfigure",),
|
||||
("write_project", False, ["lwip"]),
|
||||
("run_reconfigure",),
|
||||
("save", ["lwip"]),
|
||||
("build",),
|
||||
]
|
||||
|
||||
|
||||
def test_run_compile_discovery_failure_stops_before_full_write(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A failed discovery configure returns its exit code and never writes
|
||||
the full CMakeLists, a cache entry or a build."""
|
||||
_setup_build(setup_core)
|
||||
rc, calls = _record_compile_calls(None, reconfigure_rcs=(2,))
|
||||
assert rc == 2
|
||||
assert calls == [("write_project", True, None), ("run_reconfigure",)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("discovered", [None, []], ids=["no_manifest", "empty"])
|
||||
def test_run_compile_fails_when_discovery_finds_nothing(
|
||||
setup_core: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
discovered: list[str] | None,
|
||||
) -> None:
|
||||
_setup_build(setup_core)
|
||||
rc, calls = _record_compile_calls(None, saved=discovered)
|
||||
assert rc == 1
|
||||
assert calls == [("write_project", True, None), ("run_reconfigure",)]
|
||||
assert "found no built-in ESP-IDF components" in caplog.text
|
||||
|
||||
|
||||
def test_run_compile_does_not_cache_a_list_that_failed_to_configure(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
_setup_build(setup_core)
|
||||
rc, calls = _record_compile_calls(None, saved=["lwip"], reconfigure_rcs=(0, 3))
|
||||
assert rc == 3
|
||||
assert ("save", ["lwip"]) not in calls
|
||||
assert ("build",) not in calls
|
||||
|
||||
|
||||
def test_run_compile_cache_hit_skips_discovery(setup_core: Path) -> None:
|
||||
"""A cached list goes straight to the full write; the explicit reconfigure
|
||||
after it (#18730) still runs."""
|
||||
_setup_build(setup_core)
|
||||
rc, calls = _record_compile_calls(["esp_timer", "lwip"])
|
||||
assert rc == 0
|
||||
assert calls == [
|
||||
("write_project", False, ["esp_timer", "lwip"]),
|
||||
("run_reconfigure",),
|
||||
("build",),
|
||||
]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _cache_env(tmp_path: Path, excluded: str) -> Iterator[Path]:
|
||||
"""Patch everything the cache key derives from onto a temp IDF tree and
|
||||
yield that tree's path."""
|
||||
idf_path = tmp_path / "idf"
|
||||
(idf_path / "components").mkdir(parents=True, exist_ok=True)
|
||||
with (
|
||||
patch.object(toolchain, "_get_idf_path", return_value=idf_path),
|
||||
patch.dict(CORE.data, {KEY_ESP32: {KEY_VARIANT: "ESP32"}}),
|
||||
patch.dict(CORE.cmake_args, {"EXCLUDE_COMPONENTS": excluded}),
|
||||
):
|
||||
yield idf_path
|
||||
|
||||
|
||||
def test_component_cache_round_trip(setup_core: Path, tmp_path: Path) -> None:
|
||||
"""A saved list is read back until it is dropped."""
|
||||
_setup_build(setup_core)
|
||||
with _cache_env(tmp_path, "fatfs") as idf_path:
|
||||
for name in ("lwip", "esp_timer"):
|
||||
(idf_path / "components" / name).mkdir()
|
||||
assert toolchain.load_cached_builtin_components() is None
|
||||
toolchain.save_cached_builtin_components(["esp_timer", "lwip"])
|
||||
assert toolchain.load_cached_builtin_components() == ["esp_timer", "lwip"]
|
||||
toolchain._builtin_component_cache_path().unlink()
|
||||
assert toolchain.load_cached_builtin_components() is None
|
||||
|
||||
|
||||
def test_component_cache_misses_on_key_change_or_missing_component(
|
||||
setup_core: Path, tmp_path: Path
|
||||
) -> None:
|
||||
"""A different exclusion set uses another entry, an entry naming a
|
||||
component that no longer exists is ignored, and a custom IDF_PATH is
|
||||
never cached."""
|
||||
_setup_build(setup_core)
|
||||
with _cache_env(tmp_path, "fatfs") as idf_path:
|
||||
(idf_path / "components" / "lwip").mkdir()
|
||||
toolchain.save_cached_builtin_components(["lwip"])
|
||||
path = toolchain._builtin_component_cache_path()
|
||||
assert path.parent == idf_path / ".esphome_component_lists"
|
||||
assert path.name.startswith("esp32-")
|
||||
assert toolchain.load_cached_builtin_components() == ["lwip"]
|
||||
with patch.dict(os.environ, {"IDF_PATH": str(idf_path)}):
|
||||
assert toolchain.load_cached_builtin_components() is None
|
||||
with _cache_env(tmp_path, "fatfs;unity"):
|
||||
assert toolchain.load_cached_builtin_components() is None
|
||||
with _cache_env(tmp_path, "fatfs") as idf_path:
|
||||
path.write_text(json.dumps(["lwip", "gone"]))
|
||||
assert toolchain.load_cached_builtin_components() is None
|
||||
# A plain file with the right name is not a component directory.
|
||||
(idf_path / "components" / "gone").write_text("not a directory")
|
||||
assert toolchain.load_cached_builtin_components() is None
|
||||
|
||||
|
||||
def test_component_cache_save_skips_empty_list_or_custom_idf_path(
|
||||
setup_core: Path, tmp_path: Path
|
||||
) -> None:
|
||||
_setup_build(setup_core)
|
||||
with _cache_env(tmp_path, "") as idf_path:
|
||||
toolchain.save_cached_builtin_components([])
|
||||
with patch.dict(os.environ, {"IDF_PATH": str(idf_path)}):
|
||||
toolchain.save_cached_builtin_components(["lwip"])
|
||||
assert not (idf_path / ".esphome_component_lists").exists()
|
||||
|
||||
|
||||
def test_component_cache_write_failure_is_logged(
|
||||
setup_core: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
_setup_build(setup_core)
|
||||
with (
|
||||
_cache_env(tmp_path, ""),
|
||||
patch.object(toolchain, "write_file", side_effect=EsphomeError("disk full")),
|
||||
):
|
||||
toolchain.save_cached_builtin_components(["lwip"])
|
||||
assert toolchain.load_cached_builtin_components() is None
|
||||
assert "Could not write component list cache" in caplog.text
|
||||
|
||||
|
||||
def test_component_cache_ignores_corrupt_file(setup_core: Path, tmp_path: Path) -> None:
|
||||
_setup_build(setup_core)
|
||||
with _cache_env(tmp_path, ""):
|
||||
path = toolchain._builtin_component_cache_path()
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text("{not json")
|
||||
assert toolchain.load_cached_builtin_components() is None
|
||||
path.write_text(json.dumps({"components": ["lwip"]}))
|
||||
assert toolchain.load_cached_builtin_components() is None
|
||||
|
||||
|
||||
def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None:
|
||||
"""compile_process_limit is forwarded to run_idf_py as the job limit."""
|
||||
_setup_build(setup_core)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
@@ -31,6 +32,7 @@ from esphome.framework_helpers import (
|
||||
_zip_extract_all,
|
||||
archive_extract_all,
|
||||
create_venv,
|
||||
download_and_extract,
|
||||
download_from_mirrors,
|
||||
download_with_resume,
|
||||
get_project_compile_flags,
|
||||
@@ -1339,20 +1341,20 @@ class TestDownloadFromMirrors:
|
||||
assert url == "https://example.com/f"
|
||||
assert target.read_bytes() == b"filedata"
|
||||
|
||||
def test_file_object_target_reports_progress(self) -> None:
|
||||
"""The library prefetch's production path: a file-object target
|
||||
streams through the mirror fallback and ticks the tracker."""
|
||||
buf = io.BytesIO()
|
||||
def test_progress_callback_reports_bytes(self, tmp_path: Path) -> None:
|
||||
"""The library prefetch's production path: the mirror download ticks
|
||||
the caller's tracker instead of drawing its own bar."""
|
||||
target = tmp_path / "f.bin"
|
||||
ticks: list[int] = []
|
||||
with patch(
|
||||
"requests.get",
|
||||
return_value=_mock_response(b"filedata"),
|
||||
):
|
||||
url = download_from_mirrors(
|
||||
["https://example.com/f"], {}, buf, progress=ticks.append
|
||||
["https://example.com/f"], {}, target, progress=ticks.append
|
||||
)
|
||||
assert url == "https://example.com/f"
|
||||
assert buf.getvalue() == b"filedata"
|
||||
assert target.read_bytes() == b"filedata"
|
||||
assert ticks and ticks[-1] == len(b"filedata")
|
||||
|
||||
def test_substitutions_applied_to_url(self, tmp_path: Path) -> None:
|
||||
@@ -1460,8 +1462,8 @@ class TestDownloadFromMirrors:
|
||||
ei.value
|
||||
)
|
||||
|
||||
def test_falls_back_to_second_mirror(self) -> None:
|
||||
buf = io.BytesIO()
|
||||
def test_falls_back_to_second_mirror(self, tmp_path: Path) -> None:
|
||||
target = tmp_path / "f.bin"
|
||||
with patch(
|
||||
"requests.get",
|
||||
side_effect=[_mock_response(b"", ok=False), _mock_response(b"second")],
|
||||
@@ -1469,18 +1471,18 @@ class TestDownloadFromMirrors:
|
||||
url = download_from_mirrors(
|
||||
["https://mirror1.com/f", "https://mirror2.com/f"],
|
||||
{},
|
||||
buf,
|
||||
target,
|
||||
)
|
||||
assert url == "https://mirror2.com/f"
|
||||
assert buf.getvalue() == b"second"
|
||||
assert target.read_bytes() == b"second"
|
||||
|
||||
def test_mid_stream_drop_resumes_same_mirror(self) -> None:
|
||||
def test_mid_stream_drop_resumes_same_mirror(self, tmp_path: Path) -> None:
|
||||
"""A mid-stream failure retries the same mirror with Range and
|
||||
If-Range headers, keeping the bytes already received, before falling
|
||||
to the next."""
|
||||
first = _interrupted_response(b"1234", etag='"v1"')
|
||||
first.headers = {**first.headers, "content-length": "8"}
|
||||
buf = io.BytesIO()
|
||||
target = tmp_path / "f.bin"
|
||||
with patch(
|
||||
"requests.get",
|
||||
side_effect=[first, _resumed_response(b"5678")],
|
||||
@@ -1488,10 +1490,10 @@ class TestDownloadFromMirrors:
|
||||
url = download_from_mirrors(
|
||||
["https://mirror1.com/f", "https://mirror2.com/f"],
|
||||
{},
|
||||
buf,
|
||||
target,
|
||||
)
|
||||
assert url == "https://mirror1.com/f"
|
||||
assert buf.getvalue() == b"12345678"
|
||||
assert target.read_bytes() == b"12345678"
|
||||
assert mock_get.call_count == 2
|
||||
assert mock_get.call_args_list[1][0][0] == "https://mirror1.com/f"
|
||||
# the resume is conditional on the content being unchanged
|
||||
@@ -1500,48 +1502,6 @@ class TestDownloadFromMirrors:
|
||||
"If-Range": '"v1"',
|
||||
}
|
||||
|
||||
def test_mid_stream_drop_without_validator_restarts(self) -> None:
|
||||
"""A server offering no ETag/Last-Modified cannot be resumed safely;
|
||||
the retry restarts from zero instead of stitching unverified bytes."""
|
||||
buf = io.BytesIO()
|
||||
with patch(
|
||||
"requests.get",
|
||||
side_effect=[_interrupted_response(b"1234"), _mock_response(b"full")],
|
||||
) as mock_get:
|
||||
download_from_mirrors(["https://mirror1.com/f"], {}, buf)
|
||||
assert buf.getvalue() == b"full"
|
||||
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
|
||||
|
||||
def test_drop_after_last_byte_recovers_via_416(self) -> None:
|
||||
"""A connection drop after the final body byte leaves a complete file;
|
||||
the retry's 416 answer plus the length check turn it into success
|
||||
instead of a wasted refetch."""
|
||||
first = _interrupted_response(b"1234", etag='"v1"')
|
||||
first.headers = {**first.headers, "content-length": "4"}
|
||||
r416 = _mock_response(b"", ok=False)
|
||||
r416.status_code = 416
|
||||
buf = io.BytesIO()
|
||||
with patch("requests.get", side_effect=[first, r416]) as mock_get:
|
||||
url = download_from_mirrors(["https://mirror1.com/f"], {}, buf)
|
||||
assert url == "https://mirror1.com/f"
|
||||
assert buf.getvalue() == b"1234"
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
def test_mirror_drop_without_length_restarts(self) -> None:
|
||||
"""With no content-length there is no way to prove a stitched file
|
||||
complete, so the retry restarts even though a validator exists."""
|
||||
buf = io.BytesIO()
|
||||
with patch(
|
||||
"requests.get",
|
||||
side_effect=[
|
||||
_interrupted_response(b"1234", etag='"v1"'),
|
||||
_mock_response(b"full"),
|
||||
],
|
||||
) as mock_get:
|
||||
download_from_mirrors(["https://mirror1.com/f"], {}, buf)
|
||||
assert buf.getvalue() == b"full"
|
||||
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
|
||||
|
||||
def test_path_target_resumes_across_runs(self, tmp_path: Path) -> None:
|
||||
"""A path target routes through download_with_resume: a part file and
|
||||
metadata from a previous run resume instead of restarting."""
|
||||
@@ -1573,32 +1533,14 @@ class TestDownloadFromMirrors:
|
||||
assert url == "https://mirror2.com/f"
|
||||
assert dest.read_bytes() == b"data"
|
||||
|
||||
def test_resumed_short_body_fails_length_check(self) -> None:
|
||||
"""A stitched file whose final length disagrees with the advertised
|
||||
total is rejected instead of reported as success."""
|
||||
first = _interrupted_response(b"1234", etag='"v1"')
|
||||
first.headers = {**first.headers, "content-length": "8"}
|
||||
# the resume ends early (5 of 8 bytes); the poisoned part is then
|
||||
# discarded and the fresh retry also delivers a short body
|
||||
short_resume = _resumed_response(b"5")
|
||||
short_fresh = _mock_response(b"56")
|
||||
short_fresh.headers = {**short_fresh.headers, "content-length": "8"}
|
||||
buf = io.BytesIO()
|
||||
with (
|
||||
patch("requests.get", side_effect=[first, short_resume, short_fresh]),
|
||||
pytest.raises(EsphomeError, match="all mirrors"),
|
||||
):
|
||||
download_from_mirrors(["https://mirror1.com/f"], {}, buf)
|
||||
|
||||
def test_failed_mirror_leftovers_not_kept_for_next_mirror(self) -> None:
|
||||
"""Bytes from a mirror that failed all attempts must not leak into the
|
||||
next mirror's download (no bogus Range request, fresh content)."""
|
||||
exhausted = [_interrupted_response(b"AAAA", etag='"a1"')]
|
||||
for _ in range(2):
|
||||
r = _interrupted_response(b"BB")
|
||||
r.status_code = 206
|
||||
exhausted.append(r)
|
||||
buf = io.BytesIO()
|
||||
def test_failed_mirror_leftovers_not_resumed_on_next_mirror(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""A part file left by a mirror that failed all attempts must not be
|
||||
stitched onto the next mirror's download (its meta names the other
|
||||
URL, so the retry restarts from zero without a Range request)."""
|
||||
exhausted = [_interrupted_response(b"AAAA") for _ in range(3)]
|
||||
target = tmp_path / "f.bin"
|
||||
with patch(
|
||||
"requests.get",
|
||||
side_effect=exhausted + [_mock_response(b"clean")],
|
||||
@@ -1606,15 +1548,17 @@ class TestDownloadFromMirrors:
|
||||
url = download_from_mirrors(
|
||||
["https://mirror1.com/f", "https://mirror2.com/f"],
|
||||
{},
|
||||
buf,
|
||||
target,
|
||||
)
|
||||
assert url == "https://mirror2.com/f"
|
||||
assert buf.getvalue() == b"clean"
|
||||
assert target.read_bytes() == b"clean"
|
||||
# the second mirror starts fresh, without a Range header
|
||||
assert mock_get.call_args_list[3][0][0] == "https://mirror2.com/f"
|
||||
assert "Range" not in mock_get.call_args_list[3][1]["headers"]
|
||||
|
||||
def test_all_mirrors_fail_raises_error_listing_every_attempt(self) -> None:
|
||||
def test_all_mirrors_fail_raises_error_listing_every_attempt(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
with (
|
||||
patch(
|
||||
"requests.get",
|
||||
@@ -1625,7 +1569,7 @@ class TestDownloadFromMirrors:
|
||||
download_from_mirrors(
|
||||
["https://mirror1.com/f", "https://mirror2.com/f"],
|
||||
{},
|
||||
io.BytesIO(),
|
||||
tmp_path / "out.bin",
|
||||
)
|
||||
# Every attempted URL appears in the message, and the first mirror's
|
||||
# exception (the primary URL, usually the one that matters) is chained.
|
||||
@@ -1641,16 +1585,6 @@ class TestDownloadFromMirrors:
|
||||
with pytest.raises(TypeError, match="target must be"):
|
||||
download_from_mirrors(["https://example.com/f"], {}, 42) # type: ignore[arg-type]
|
||||
|
||||
def test_file_like_target_written(self) -> None:
|
||||
buf = io.BytesIO()
|
||||
with patch(
|
||||
"requests.get",
|
||||
return_value=_mock_response(b"bytes"),
|
||||
):
|
||||
download_from_mirrors(["https://example.com/f"], {}, buf)
|
||||
buf.seek(0)
|
||||
assert buf.read() == b"bytes"
|
||||
|
||||
def test_progress_bar_shown_when_content_length_known(self, tmp_path: Path) -> None:
|
||||
r = _mock_response(b"1234567890")
|
||||
r.headers = {"content-length": "10"}
|
||||
@@ -1676,13 +1610,10 @@ class TestDownloadFromMirrors:
|
||||
assert target.exists()
|
||||
assert target.read_bytes() == b""
|
||||
|
||||
@pytest.mark.parametrize("target_kind", ["path", "file-like"])
|
||||
def test_transient_failure_retries_mirror_sweep(
|
||||
self, tmp_path: Path, target_kind: str
|
||||
) -> None:
|
||||
def test_transient_failure_retries_mirror_sweep(self, tmp_path: Path) -> None:
|
||||
"""A transient connect error on the only applicable mirror retries the
|
||||
whole mirror list with backoff instead of failing the build."""
|
||||
target = tmp_path / "idf.tar.xz" if target_kind == "path" else io.BytesIO()
|
||||
target = tmp_path / "idf.tar.xz"
|
||||
with (
|
||||
patch(
|
||||
"requests.get",
|
||||
@@ -1695,33 +1626,10 @@ class TestDownloadFromMirrors:
|
||||
):
|
||||
url = download_from_mirrors(["https://mirror1.com/f"], {}, target)
|
||||
assert url == "https://mirror1.com/f"
|
||||
data = target.read_bytes() if target_kind == "path" else target.getvalue()
|
||||
assert data == b"data"
|
||||
assert target.read_bytes() == b"data"
|
||||
assert mock_get.call_count == 2
|
||||
mock_sleep.assert_called_once_with(2)
|
||||
|
||||
def test_backoff_tick_reports_filelike_bytes(self) -> None:
|
||||
"""For a file-like target the backoff tick carries f.tell(), so the
|
||||
combined bar holds steady through the sweep retry."""
|
||||
target = io.BytesIO()
|
||||
ticks: list[int] = []
|
||||
with (
|
||||
patch(
|
||||
"requests.get",
|
||||
side_effect=[
|
||||
req.ConnectionError("down"),
|
||||
_mock_response(b"data"),
|
||||
],
|
||||
),
|
||||
patch("esphome.framework_helpers._cancellable_sleep") as mock_sleep,
|
||||
):
|
||||
download_from_mirrors(
|
||||
["https://mirror1.com/f"], {}, target, progress=ticks.append
|
||||
)
|
||||
# No bytes had streamed at backoff time, so the tick carries 0
|
||||
assert mock_sleep.call_args == call(2, ticks.append, 0)
|
||||
assert target.getvalue() == b"data"
|
||||
|
||||
def test_backoff_tick_reports_partial_bytes(self, tmp_path: Path) -> None:
|
||||
"""The backoff tick carries the bytes already in the part file, so a
|
||||
combined bar holds steady instead of rewinding to zero."""
|
||||
@@ -1831,41 +1739,83 @@ class TestDownloadFromMirrors:
|
||||
assert isinstance(ei.value.__cause__, req.ConnectionError)
|
||||
mock_sleep.assert_called_once_with(2)
|
||||
|
||||
def test_exhausted_mid_stream_attempts_not_swept(self) -> None:
|
||||
"""A file-like mirror that spent all its mid-stream attempts is not
|
||||
retried again at the sweep level (unlike a path target, it has no
|
||||
part file to resume from on a later sweep)."""
|
||||
buf = io.BytesIO()
|
||||
def test_exhausted_mid_stream_attempts_not_swept(self, tmp_path: Path) -> None:
|
||||
"""A mirror that spent all its mid-stream attempts fails permanently
|
||||
instead of re-arming the sweep, and its part file survives so the
|
||||
next esphome run resumes it."""
|
||||
with (
|
||||
patch(
|
||||
"requests.get",
|
||||
side_effect=[_interrupted_response(b"1234") for _ in range(3)],
|
||||
) as mock_get,
|
||||
patch("esphome.framework_helpers.time.sleep") as mock_sleep,
|
||||
pytest.raises(EsphomeError, match="failed after 3 attempts"),
|
||||
pytest.raises(EsphomeError, match="after 3 attempts"),
|
||||
):
|
||||
download_from_mirrors(["https://mirror1.com/f"], {}, buf)
|
||||
download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin")
|
||||
assert mock_get.call_count == 3
|
||||
mock_sleep.assert_not_called()
|
||||
assert (tmp_path / "out.bin.part").exists()
|
||||
|
||||
|
||||
class TestDownloadAndExtract:
|
||||
def test_downloads_extracts_and_deletes_archive(self, tmp_path: Path) -> None:
|
||||
content = gzip.compress(
|
||||
_make_tar([_reg("file.txt")], {"file.txt": b"data"}).getvalue()
|
||||
)
|
||||
dest = tmp_path / "out"
|
||||
with patch("requests.get", return_value=_mock_response(content)):
|
||||
url = download_and_extract(
|
||||
["https://example.com/lib.tar.gz"],
|
||||
{},
|
||||
tmp_path / "lib.archive",
|
||||
dest,
|
||||
)
|
||||
assert url == "https://example.com/lib.tar.gz"
|
||||
assert (dest / "file.txt").read_bytes() == b"data"
|
||||
# the archive is consumed; only the extraction remains
|
||||
assert not (tmp_path / "lib.archive").exists()
|
||||
|
||||
def test_locked_archive_does_not_mask_result(self, tmp_path: Path) -> None:
|
||||
"""A cleanup unlink blocked by e.g. an AV handle (Windows) must not
|
||||
replace the extraction result; the archive simply survives."""
|
||||
content = gzip.compress(
|
||||
_make_tar([_reg("file.txt")], {"file.txt": b"data"}).getvalue()
|
||||
)
|
||||
real_unlink = Path.unlink
|
||||
|
||||
def locked_unlink(self: Path, missing_ok: bool = False) -> None:
|
||||
if self.name.endswith(".archive"):
|
||||
raise PermissionError("held by antivirus")
|
||||
real_unlink(self, missing_ok=missing_ok)
|
||||
|
||||
def test_mid_stream_drop_then_connect_error_not_swept(self) -> None:
|
||||
"""A connect error on a later attempt (after a mid-stream drop spent
|
||||
one) also counts as spent budget and does not re-arm the sweep."""
|
||||
buf = io.BytesIO()
|
||||
with (
|
||||
patch(
|
||||
"requests.get",
|
||||
side_effect=[
|
||||
_interrupted_response(b"1234"),
|
||||
req.ConnectionError("down"),
|
||||
],
|
||||
) as mock_get,
|
||||
patch("esphome.framework_helpers.time.sleep") as mock_sleep,
|
||||
pytest.raises(EsphomeError, match="failed after 2 attempts"),
|
||||
patch("requests.get", return_value=_mock_response(content)),
|
||||
patch("pathlib.Path.unlink", locked_unlink),
|
||||
):
|
||||
download_from_mirrors(["https://mirror1.com/f"], {}, buf)
|
||||
assert mock_get.call_count == 2
|
||||
mock_sleep.assert_not_called()
|
||||
url = download_and_extract(
|
||||
["https://example.com/lib.tar.gz"],
|
||||
{},
|
||||
tmp_path / "lib.archive",
|
||||
tmp_path / "out",
|
||||
)
|
||||
assert url == "https://example.com/lib.tar.gz"
|
||||
assert (tmp_path / "out" / "file.txt").read_bytes() == b"data"
|
||||
assert (tmp_path / "lib.archive").exists() # left behind, harmless
|
||||
|
||||
def test_corrupt_archive_deleted_on_extract_failure(self, tmp_path: Path) -> None:
|
||||
"""A complete-but-corrupt archive must not survive to poison the next
|
||||
run; without a checksum only a failed extraction can expose it."""
|
||||
with (
|
||||
patch("requests.get", return_value=_mock_response(b"not an archive")),
|
||||
pytest.raises(ValueError, match="Unsupported archive format"),
|
||||
):
|
||||
download_and_extract(
|
||||
["https://example.com/lib.tar.gz"],
|
||||
{},
|
||||
tmp_path / "lib.archive",
|
||||
tmp_path / "out",
|
||||
)
|
||||
assert not (tmp_path / "lib.archive").exists()
|
||||
|
||||
|
||||
def test_importing_framework_helpers_does_not_import_requests() -> None:
|
||||
|
||||
@@ -4,7 +4,9 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Generator
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import socket
|
||||
import threading
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
@@ -61,6 +63,41 @@ def test_ensure_happy_eyeballs_patches_and_is_idempotent(
|
||||
assert urllib3.util.connection.create_connection is patched
|
||||
|
||||
|
||||
def test_ensure_happy_eyeballs_concurrent_first_calls_patch_once(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Worker threads fanning out (download_content_many, run_batch_downloads)
|
||||
may race the first call; the replacement is built exactly once."""
|
||||
import urllib3.util.connection
|
||||
|
||||
from esphome import happy_eyeballs
|
||||
|
||||
def stock(*args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(urllib3.util.connection, "create_connection", stock)
|
||||
|
||||
barrier = threading.Barrier(8)
|
||||
builds: list[int] = []
|
||||
real_make = happy_eyeballs._make_create_connection
|
||||
|
||||
def counting_make() -> Any:
|
||||
builds.append(1)
|
||||
return real_make()
|
||||
|
||||
monkeypatch.setattr(happy_eyeballs, "_make_create_connection", counting_make)
|
||||
|
||||
def racer() -> None:
|
||||
barrier.wait(timeout=10)
|
||||
ensure_happy_eyeballs()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as ex:
|
||||
list(ex.map(lambda _: racer(), range(8)))
|
||||
|
||||
assert builds == [1]
|
||||
assert urllib3.util.connection.create_connection._esphome_patched
|
||||
|
||||
|
||||
def test_connects_and_restores_socket_state(
|
||||
create_connection: Any, listener: tuple[str, int], mock_gai: Any
|
||||
) -> None:
|
||||
|
||||
@@ -7,7 +7,11 @@ import pytest
|
||||
import requests as req
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.net_retry import fetch_with_retry, is_transient_download_error
|
||||
from esphome.net_retry import (
|
||||
fetch_with_retry,
|
||||
http_request,
|
||||
is_transient_download_error,
|
||||
)
|
||||
|
||||
|
||||
def _http_error(status: int) -> req.HTTPError:
|
||||
@@ -141,3 +145,40 @@ class TestFetchWithRetry:
|
||||
assert mock_sleep.call_args_list == [call(2), call(4)]
|
||||
assert "(attempt 2/3)" in caplog.text
|
||||
assert "(attempt 3/3)" in caplog.text
|
||||
|
||||
|
||||
class TestHttpRequest:
|
||||
def test_applies_happy_eyeballs_and_forwards_arguments(self) -> None:
|
||||
with (
|
||||
patch("esphome.net_retry.ensure_happy_eyeballs") as mock_he,
|
||||
patch("requests.get", return_value=MagicMock()) as mock_get,
|
||||
):
|
||||
resp = http_request(
|
||||
"GET",
|
||||
"https://example.com/f",
|
||||
timeout=30,
|
||||
stream=True,
|
||||
headers={"Range": "bytes=4-"},
|
||||
)
|
||||
mock_he.assert_called_once_with()
|
||||
assert resp is mock_get.return_value
|
||||
assert mock_get.call_args == call(
|
||||
"https://example.com/f",
|
||||
timeout=30,
|
||||
stream=True,
|
||||
headers={"Range": "bytes=4-"},
|
||||
allow_redirects=True,
|
||||
)
|
||||
|
||||
def test_dispatches_head_through_requests_head(self) -> None:
|
||||
"""Dispatch goes through requests.get/head so tests patching those
|
||||
entry points keep working."""
|
||||
with patch("requests.head", return_value=MagicMock()) as mock_head:
|
||||
http_request("HEAD", "https://example.com/f", timeout=(5, 30))
|
||||
assert mock_head.call_args[1]["timeout"] == (5, 30)
|
||||
|
||||
def test_no_status_handling(self) -> None:
|
||||
"""Error statuses are the caller's problem; nothing raises here."""
|
||||
resp = MagicMock(status_code=404)
|
||||
with patch("requests.get", return_value=resp):
|
||||
assert http_request("GET", "https://example.com/f", timeout=1) is resp
|
||||
|
||||
@@ -107,11 +107,13 @@ def mock_nrf52_ops():
|
||||
patch(
|
||||
"esphome.components.nrf52.framework.run_command_ok", return_value=True
|
||||
) as mock_run_cmd,
|
||||
# download_and_extract resolves its internals in framework_helpers,
|
||||
# so the download/extract seams are patched there.
|
||||
patch(
|
||||
"esphome.components.nrf52.framework.download_from_mirrors",
|
||||
"esphome.framework_helpers.download_from_mirrors",
|
||||
return_value="https://example.com/tc.tar.xz",
|
||||
) as mock_download,
|
||||
patch("esphome.components.nrf52.framework.archive_extract_all") as mock_extract,
|
||||
patch("esphome.framework_helpers.archive_extract_all") as mock_extract,
|
||||
):
|
||||
yield SimpleNamespace(
|
||||
rmdir=mock_rmdir,
|
||||
|
||||
@@ -162,16 +162,12 @@ def test_urlsource_download_extracts_then_reuses_marker(
|
||||
):
|
||||
monkeypatch.setattr(lib, "rmdir", lambda path, msg="": None)
|
||||
dl_calls: list[list[str]] = []
|
||||
monkeypatch.setattr(
|
||||
lib,
|
||||
"download_from_mirrors",
|
||||
lambda urls, headers, f, progress=None: dl_calls.append(urls),
|
||||
)
|
||||
|
||||
def fake_extract(fileobj, path):
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
def fake_download_and_extract(urls, subs, archive_path, extract_dir, **kwargs):
|
||||
dl_calls.append(urls)
|
||||
Path(extract_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setattr(lib, "archive_extract_all", fake_extract)
|
||||
monkeypatch.setattr(lib, "download_and_extract", fake_download_and_extract)
|
||||
|
||||
src = URLSource("http://example.test/lib.tar.gz")
|
||||
out = src.download("mylib")
|
||||
@@ -191,6 +187,25 @@ def test_urlsource_download_extracts_then_reuses_marker(
|
||||
assert "Downloading" not in caplog.text
|
||||
|
||||
|
||||
def test_urlsource_downloads_to_sibling_archive_path(setup_core, monkeypatch):
|
||||
"""The archive downloads to a deterministic path next to the cache dir
|
||||
(not a random temp file), so an interrupted download's .part file
|
||||
resumes on the next run."""
|
||||
monkeypatch.setattr(lib, "rmdir", lambda path, msg="": None)
|
||||
targets: list[Path] = []
|
||||
|
||||
def fake_download_and_extract(urls, subs, archive_path, extract_dir, **kwargs):
|
||||
targets.append(Path(archive_path))
|
||||
Path(extract_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setattr(lib, "download_and_extract", fake_download_and_extract)
|
||||
|
||||
src = URLSource("http://example.test/lib.tar.gz")
|
||||
out = src.download("mylib")
|
||||
|
||||
assert targets == [out.with_name(f"{out.name}.archive")]
|
||||
|
||||
|
||||
def test_resolve_registry_version_raises_without_pkg_file(monkeypatch):
|
||||
registry = lib._make_registry_client()
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -6,7 +6,7 @@ from contextlib import contextmanager
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -32,12 +32,11 @@ def test_registry_download_resolves_once_per_process() -> None:
|
||||
]
|
||||
}
|
||||
|
||||
def fake_download(mirrors, substitutions, target):
|
||||
calls.append(substitutions)
|
||||
target.write(json.dumps(payload).encode())
|
||||
return mirrors[0]
|
||||
def fake_request(method, url, **kwargs):
|
||||
calls.append(url)
|
||||
return _http_response(json.dumps(payload))
|
||||
|
||||
with patch.object(registry, "download_from_mirrors", side_effect=fake_download):
|
||||
with patch.object(registry, "http_request", side_effect=fake_request):
|
||||
first = registry.registry_download("o/pkg", "1.0.0")
|
||||
second = registry.registry_download("o/pkg", "1.0.0")
|
||||
assert first == second
|
||||
@@ -105,41 +104,47 @@ def test_get_systype_windows_empty_machine() -> None:
|
||||
assert registry.get_systype() == "windows_amd64"
|
||||
|
||||
|
||||
def _http_response(text: str) -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.text = text
|
||||
resp.raise_for_status.return_value = None
|
||||
return resp
|
||||
|
||||
|
||||
def _registry_response(files: list[dict]):
|
||||
"""Patch the shared downloader to serve a canned registry response."""
|
||||
"""Patch the consolidated HTTP path to serve a canned registry response."""
|
||||
payload = {"versions": [{"name": "1.0.0", "files": files}]}
|
||||
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(json.dumps(payload).encode())
|
||||
return mirrors[0].format(**substitutions)
|
||||
|
||||
return patch.object(registry, "download_from_mirrors", side_effect=fake_download)
|
||||
return patch.object(
|
||||
registry, "http_request", return_value=_http_response(json.dumps(payload))
|
||||
)
|
||||
|
||||
|
||||
def test_registry_download_uses_shared_downloader() -> None:
|
||||
"""The metadata fetch delegates its retries and error reporting to
|
||||
download_from_mirrors; failures surface unchanged."""
|
||||
def test_registry_download_uses_shared_http_path() -> None:
|
||||
"""The metadata fetch delegates to the consolidated http_request path;
|
||||
request failures surface as a named EsphomeError."""
|
||||
import requests as req
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
registry,
|
||||
"download_from_mirrors",
|
||||
side_effect=EsphomeError("Failed to download from all mirrors"),
|
||||
) as mock_download,
|
||||
pytest.raises(EsphomeError, match="Failed to download from all mirrors"),
|
||||
"http_request",
|
||||
side_effect=req.exceptions.ConnectionError("registry down"),
|
||||
) as mock_request,
|
||||
pytest.raises(EsphomeError, match="Could not fetch registry metadata"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
(mirrors, substitutions, _), _ = mock_download.call_args
|
||||
assert mirrors == [registry._REGISTRY_URL]
|
||||
assert substitutions == {"package": "pkg"}
|
||||
(method, url), _ = mock_request.call_args
|
||||
assert method == "GET"
|
||||
assert url == registry._REGISTRY_URL.format(package="pkg")
|
||||
|
||||
|
||||
def test_registry_download_invalid_json_is_clean() -> None:
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(b"<html>not json</html>")
|
||||
return "http://x"
|
||||
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors", side_effect=fake_download),
|
||||
patch.object(
|
||||
registry,
|
||||
"http_request",
|
||||
return_value=_http_response("<html>not json</html>"),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="invalid JSON"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
@@ -224,14 +229,14 @@ def test_registry_download_no_system_match() -> None:
|
||||
|
||||
|
||||
def test_registry_download_version_not_found() -> None:
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(
|
||||
json.dumps({"versions": [{"name": "2.0.0", "files": []}]}).encode()
|
||||
)
|
||||
return "http://x"
|
||||
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors", side_effect=fake_download),
|
||||
patch.object(
|
||||
registry,
|
||||
"http_request",
|
||||
return_value=_http_response(
|
||||
json.dumps({"versions": [{"name": "2.0.0", "files": []}]})
|
||||
),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="not found"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
@@ -391,12 +396,12 @@ def test_registry_download_empty_system_list_does_not_match() -> None:
|
||||
def test_registry_download_unexpected_payload_is_named() -> None:
|
||||
"""An error envelope without a versions list is not 'version not found'."""
|
||||
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(json.dumps({"message": "rate limited"}).encode())
|
||||
return "http://x"
|
||||
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors", side_effect=fake_download),
|
||||
patch.object(
|
||||
registry,
|
||||
"http_request",
|
||||
return_value=_http_response(json.dumps({"message": "rate limited"})),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
@@ -441,28 +446,26 @@ def test_registry_download_non_dict_version_entry_is_named() -> None:
|
||||
"""A versions list of bare strings is an unexpected payload, not an
|
||||
AttributeError traceback."""
|
||||
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(json.dumps({"versions": ["1.0.0", "2.0.0"]}).encode())
|
||||
return "http://x"
|
||||
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors", side_effect=fake_download),
|
||||
patch.object(
|
||||
registry,
|
||||
"http_request",
|
||||
return_value=_http_response(json.dumps({"versions": ["1.0.0", "2.0.0"]})),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_non_dict_file_entry_is_named() -> None:
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(
|
||||
json.dumps(
|
||||
{"versions": [{"name": "1.0.0", "files": ["a.tar.gz"]}]}
|
||||
).encode()
|
||||
)
|
||||
return "http://x"
|
||||
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors", side_effect=fake_download),
|
||||
patch.object(
|
||||
registry,
|
||||
"http_request",
|
||||
return_value=_http_response(
|
||||
json.dumps({"versions": [{"name": "1.0.0", "files": ["a.tar.gz"]}]})
|
||||
),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
@@ -471,12 +474,12 @@ def test_registry_download_non_dict_file_entry_is_named() -> None:
|
||||
def test_registry_download_non_dict_payload_is_named() -> None:
|
||||
"""A JSON array answer is an unexpected payload at the outermost level."""
|
||||
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(json.dumps(["1.0.0"]).encode())
|
||||
return "http://x"
|
||||
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors", side_effect=fake_download),
|
||||
patch.object(
|
||||
registry,
|
||||
"http_request",
|
||||
return_value=_http_response(json.dumps(["1.0.0"])),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
Reference in New Issue
Block a user