mirror of
https://github.com/esphome/esphome.git
synced 2026-10-04 18:11:29 +00:00
[espidf] Mirror registry components locally so configures skip the registry (#19949)
This commit is contained in:
@@ -0,0 +1,801 @@
|
||||
"""Tests for the local IDF component-registry mirror."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
import zipfile
|
||||
|
||||
from filelock import FileLock
|
||||
import pytest
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.espidf import component_mirror
|
||||
|
||||
_LOCK_TEXT = """\
|
||||
dependencies:
|
||||
bblanchon/arduinojson:
|
||||
component_hash: 5e6aff2bc5bc988b36c8e90c01e637495ee895ad3af93911860c21ec6da79b6a
|
||||
dependencies: []
|
||||
source:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 7.4.3
|
||||
espressif/mdns:
|
||||
component_hash: 3ba256ac95e07c274be53cbd73f06cb846c403b61e8fbdf1be57bdb79db7a63e
|
||||
dependencies:
|
||||
- name: idf
|
||||
require: private
|
||||
version: '>=5.0'
|
||||
source:
|
||||
registry_url: https://components.espressif.com
|
||||
type: service
|
||||
version: 1.12.0
|
||||
esphome/libsodium:
|
||||
source:
|
||||
path: /pio_components/idf/cb5ad5b7/esphome/libsodium
|
||||
type: local
|
||||
version: '*'
|
||||
fastled/fastled:
|
||||
source:
|
||||
git: https://github.com/fastled/fastled
|
||||
type: git
|
||||
version: 3.7.0
|
||||
other/private:
|
||||
source:
|
||||
registry_url: https://registry.example.com
|
||||
type: service
|
||||
version: 1.0.0
|
||||
idf:
|
||||
source:
|
||||
type: idf
|
||||
version: 5.5.5
|
||||
direct_dependencies:
|
||||
- bblanchon/arduinojson
|
||||
- espressif/mdns
|
||||
manifest_hash: f4e38c031cbb70d5070c5806fc580cce194b8c9a0b919f52a7e952f7d496dda4
|
||||
target: esp32
|
||||
version: 2.0.0
|
||||
"""
|
||||
|
||||
_MANIFEST_TEXT = """\
|
||||
dependencies:
|
||||
espressif/mdns:
|
||||
version: 1.12.0
|
||||
espressif/esp-tflite-micro:
|
||||
version: 1.3.3~1
|
||||
fastled/fastled:
|
||||
version: 3.7.0
|
||||
git: https://github.com/fastled/fastled
|
||||
esphome/libsodium:
|
||||
version: '*'
|
||||
override_path: /stub/libsodium
|
||||
espressif/ranged:
|
||||
version: ^1.2.0
|
||||
esphome/shorthand:
|
||||
version: ==2.1.0
|
||||
esphome/unquoted:
|
||||
version: 2
|
||||
"""
|
||||
|
||||
_ARDUINOJSON = component_mirror.ServiceDep("bblanchon", "arduinojson", "7.4.3")
|
||||
_MDNS = component_mirror.ServiceDep("espressif", "mdns", "1.12.0")
|
||||
_TFLITE = component_mirror.ServiceDep("espressif", "esp-tflite-micro", "1.3.3~1")
|
||||
_SHORTHAND = component_mirror.ServiceDep("esphome", "shorthand", "2.1.0")
|
||||
_MANIFEST_DEPS = [_MDNS, _TFLITE, _SHORTHAND]
|
||||
_NS_CMP_1 = component_mirror.ServiceDep("ns", "cmp", "1.0.0")
|
||||
_NS_CMP_2 = component_mirror.ServiceDep("ns", "cmp", "2.0.0")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_mirror(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", str(tmp_path / "idf_install"))
|
||||
|
||||
|
||||
def _write_lock(project_dir: Path, text: str = _LOCK_TEXT) -> Path:
|
||||
lock = project_dir / "dependencies.lock"
|
||||
lock.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock.write_text(text, encoding="utf-8")
|
||||
return lock
|
||||
|
||||
|
||||
def _ns_cmp_lock(version: str) -> str:
|
||||
return (
|
||||
"dependencies:\n"
|
||||
" ns/cmp:\n"
|
||||
" source:\n"
|
||||
" type: service\n"
|
||||
f" version: {version}\n"
|
||||
)
|
||||
|
||||
|
||||
def _write_manifest(project_dir: Path, text: str = _MANIFEST_TEXT) -> Path:
|
||||
manifest = project_dir / "src" / "idf_component.yml"
|
||||
manifest.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest.write_text(text, encoding="utf-8")
|
||||
return manifest
|
||||
|
||||
|
||||
def _add_to_mirror(mirror: Path, dep: component_mirror.ServiceDep) -> None:
|
||||
"""Lay out one component the way `registry sync` does."""
|
||||
version_dir = f"components/{dep.namespace}/{dep.name}/{dep.version}"
|
||||
archive = f"{version_dir}/{dep.namespace}__{dep.name}-v{dep.version}.zip"
|
||||
checksums = f"{version_dir}/CHECKSUMS.json"
|
||||
json_path = mirror / "components" / dep.namespace / f"{dep.name}.json"
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"name": dep.name,
|
||||
"namespace": dep.namespace,
|
||||
"versions": [
|
||||
{"version": dep.version, "url": archive, "checksums": checksums}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
archive_path = mirror / archive
|
||||
archive_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(archive_path, "w") as zf:
|
||||
zf.writestr("idf_component.yml", "")
|
||||
(mirror / checksums).write_text("{}", encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_lock_service_deps / parse_manifest_service_deps / project_service_deps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_lock_keeps_only_default_registry_service_deps(tmp_path: Path) -> None:
|
||||
"""git, local, idf and non-default registry sources cannot be mirrored."""
|
||||
deps = component_mirror.parse_lock_service_deps(_write_lock(tmp_path))
|
||||
assert deps == [_ARDUINOJSON, _MDNS]
|
||||
|
||||
|
||||
def test_parse_lock_missing_file(tmp_path: Path) -> None:
|
||||
assert component_mirror.parse_lock_service_deps(tmp_path / "none.lock") == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
pytest.param("{unbalanced", id="corrupt-yaml"),
|
||||
pytest.param("[]", id="not-a-mapping"),
|
||||
pytest.param("dependencies:\n espressif/mdns: not-a-dict\n", id="bad-entry"),
|
||||
pytest.param(_ns_cmp_lock("1.2"), id="non-string-version"),
|
||||
],
|
||||
)
|
||||
def test_parse_lock_tolerates_bad_content(tmp_path: Path, text: str) -> None:
|
||||
assert component_mirror.parse_lock_service_deps(_write_lock(tmp_path, text)) == []
|
||||
|
||||
|
||||
def test_parse_lock_unreadable_file_warns(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Read trouble other than a missing file says so instead of raising."""
|
||||
lock = tmp_path / "dependencies.lock"
|
||||
lock.mkdir() # read_text raises OSError, not FileNotFoundError
|
||||
assert component_mirror.parse_lock_service_deps(lock) == []
|
||||
assert "Could not read" in caplog.text
|
||||
|
||||
|
||||
def test_parse_lock_non_utf8_file_warns(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A non-UTF-8 lock is a warning, never a failed build."""
|
||||
lock = tmp_path / "dependencies.lock"
|
||||
lock.write_bytes(b"dependencies:\n # caf\xe9\n")
|
||||
assert component_mirror.parse_lock_service_deps(lock) == []
|
||||
assert "Could not read" in caplog.text
|
||||
|
||||
|
||||
def test_parse_lock_defaults_registry_url(tmp_path: Path) -> None:
|
||||
"""A service entry without registry_url is a default-registry dependency."""
|
||||
lock = _write_lock(tmp_path, _ns_cmp_lock("1.0.0"))
|
||||
assert component_mirror.parse_lock_service_deps(lock) == [_NS_CMP_1]
|
||||
|
||||
|
||||
def test_parse_manifest_keeps_only_exact_registry_pins(tmp_path: Path) -> None:
|
||||
"""Only exact pins survive, including the == shorthand prefix."""
|
||||
deps = component_mirror.parse_manifest_service_deps(_write_manifest(tmp_path))
|
||||
assert deps == _MANIFEST_DEPS
|
||||
|
||||
|
||||
def test_parse_manifest_missing_file(tmp_path: Path) -> None:
|
||||
assert component_mirror.parse_manifest_service_deps(tmp_path / "none.yml") == []
|
||||
|
||||
|
||||
def test_parse_manifest_lowercases_mixed_case_keys(tmp_path: Path) -> None:
|
||||
"""The registry stores lowercase paths; a mixed-case key must match them."""
|
||||
manifest = tmp_path / "idf_component.yml"
|
||||
manifest.write_text("dependencies:\n Espressif/MDNS:\n version: 1.2.0\n")
|
||||
deps = component_mirror.parse_manifest_service_deps(manifest)
|
||||
assert deps == [component_mirror.ServiceDep("espressif", "mdns", "1.2.0")]
|
||||
|
||||
|
||||
def test_parse_manifest_ranged_specs(tmp_path: Path) -> None:
|
||||
"""Ranges, not exact pins, git/override entries or an empty version,
|
||||
become refresh specs."""
|
||||
manifest = _write_manifest(
|
||||
tmp_path, _MANIFEST_TEXT + " ns/empty:\n version: ''\n"
|
||||
)
|
||||
specs = component_mirror.parse_manifest_ranged_specs(manifest)
|
||||
assert specs == ["espressif/ranged^1.2.0"]
|
||||
|
||||
|
||||
def test_project_service_deps_merges_lock_and_manifest(tmp_path: Path) -> None:
|
||||
"""The lock wins for a component in both; the manifest fills the rest."""
|
||||
lock = _write_lock(tmp_path)
|
||||
manifest = _write_manifest(tmp_path)
|
||||
assert component_mirror.project_service_deps(lock, manifest) == [
|
||||
_ARDUINOJSON,
|
||||
_MDNS,
|
||||
_TFLITE,
|
||||
_SHORTHAND,
|
||||
]
|
||||
|
||||
|
||||
def test_project_service_deps_manifest_only(tmp_path: Path) -> None:
|
||||
"""A fresh build has no lock yet; the manifest alone drives the sync."""
|
||||
manifest = _write_manifest(tmp_path)
|
||||
assert (
|
||||
component_mirror.project_service_deps(tmp_path / "dependencies.lock", manifest)
|
||||
== _MANIFEST_DEPS
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# missing_deps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_missing_deps_covered_and_not(tmp_path: Path) -> None:
|
||||
mirror = tmp_path / "mirror"
|
||||
_add_to_mirror(mirror, _MDNS)
|
||||
deps = [
|
||||
_MDNS,
|
||||
component_mirror.ServiceDep("espressif", "mdns", "1.13.0"),
|
||||
_ARDUINOJSON,
|
||||
]
|
||||
assert component_mirror.missing_deps(mirror, deps) == deps[1:]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"damage",
|
||||
[
|
||||
pytest.param(
|
||||
lambda m: next(m.rglob("*.zip")).unlink(),
|
||||
id="archive-gone",
|
||||
),
|
||||
pytest.param(
|
||||
lambda m: (m / "components/espressif/mdns/1.12.0/CHECKSUMS.json").unlink(),
|
||||
id="checksums-gone",
|
||||
),
|
||||
pytest.param(
|
||||
lambda m: (m / "components/espressif/mdns.json").write_text("{broken"),
|
||||
id="corrupt-index",
|
||||
),
|
||||
pytest.param(
|
||||
lambda m: next(m.rglob("*.zip")).write_bytes(b"PK\x03\x04torn"),
|
||||
id="truncated-archive",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_missing_deps_damaged_mirror(
|
||||
tmp_path: Path, damage: Callable[[Path], object]
|
||||
) -> None:
|
||||
"""Every file the index references must exist, and a broken index
|
||||
counts as missing, not covered."""
|
||||
mirror = tmp_path / "mirror"
|
||||
_add_to_mirror(mirror, _MDNS)
|
||||
damage(mirror)
|
||||
assert component_mirror.missing_deps(mirror, [_MDNS]) == [_MDNS]
|
||||
|
||||
|
||||
def test_missing_deps_deletes_a_torn_archive(tmp_path: Path) -> None:
|
||||
"""A torn archive is removed on detection, so the manager falls back to
|
||||
the registry even before a heal sync runs."""
|
||||
mirror = tmp_path / "mirror"
|
||||
_add_to_mirror(mirror, _MDNS)
|
||||
torn = next(mirror.rglob("*.zip"))
|
||||
torn.write_bytes(b"PK\x03\x04torn")
|
||||
assert component_mirror.missing_deps(mirror, [_MDNS]) == [_MDNS]
|
||||
assert not torn.exists()
|
||||
|
||||
|
||||
def test_missing_deps_covered_without_checksums_field(tmp_path: Path) -> None:
|
||||
"""An older payload without a checksums field only needs its archive."""
|
||||
mirror = tmp_path / "mirror"
|
||||
_add_to_mirror(mirror, _MDNS)
|
||||
index = mirror / "components/espressif/mdns.json"
|
||||
doc = json.loads(index.read_text())
|
||||
del doc["versions"][0]["checksums"]
|
||||
index.write_text(json.dumps(doc))
|
||||
assert component_mirror.missing_deps(mirror, [_MDNS]) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _publish_index / _promote
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _staged_index(tmp_path: Path, versions: list) -> Path:
|
||||
src = tmp_path / "staged.json"
|
||||
src.write_text(json.dumps({"versions": versions}), encoding="utf-8")
|
||||
return src
|
||||
|
||||
|
||||
def test_publish_index_first_sync(tmp_path: Path) -> None:
|
||||
"""No live index yet: the staged one is published as is."""
|
||||
src = _staged_index(tmp_path, [{"version": "2.0.0"}])
|
||||
dst = tmp_path / "live.json"
|
||||
component_mirror._publish_index(src, dst)
|
||||
assert json.loads(dst.read_text()) == {"versions": [{"version": "2.0.0"}]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"live", ["not json", '{"versions": null}', '{"versions": {"a": 1}}']
|
||||
)
|
||||
def test_publish_index_replaces_a_corrupt_live_index(tmp_path: Path, live: str) -> None:
|
||||
"""An unreadable live index is replaced wholesale; that is the heal."""
|
||||
src = _staged_index(tmp_path, [{"version": "2.0.0"}])
|
||||
dst = tmp_path / "live.json"
|
||||
dst.write_text(live)
|
||||
component_mirror._publish_index(src, dst)
|
||||
assert json.loads(dst.read_text()) == {"versions": [{"version": "2.0.0"}]}
|
||||
|
||||
|
||||
def test_publish_index_merges_and_filters(tmp_path: Path) -> None:
|
||||
"""Unfetched live versions survive; non-dict entries drop, not raise."""
|
||||
src = _staged_index(tmp_path, [{"version": "2.0.0"}, "junk"])
|
||||
dst = tmp_path / "live.json"
|
||||
dst.write_text(
|
||||
json.dumps({"versions": [{"version": "1.0.0"}, {"version": "2.0.0"}, "bad"]})
|
||||
)
|
||||
component_mirror._publish_index(src, dst)
|
||||
assert json.loads(dst.read_text()) == {
|
||||
"versions": [{"version": "2.0.0"}, {"version": "1.0.0"}]
|
||||
}
|
||||
|
||||
|
||||
def test_publish_index_unreadable_live_index_raises(tmp_path: Path) -> None:
|
||||
"""An I/O error is not corruption; raising keeps the live versions and
|
||||
a later run retries."""
|
||||
src = _staged_index(tmp_path, [{"version": "2.0.0"}])
|
||||
dst = tmp_path / "live.json"
|
||||
dst.mkdir() # read_text raises IsADirectoryError, not FileNotFoundError
|
||||
with pytest.raises(OSError):
|
||||
component_mirror._publish_index(src, dst)
|
||||
|
||||
|
||||
def test_promote_moves_archives_before_indexes(tmp_path: Path) -> None:
|
||||
"""A concurrent reader must never see an index entry without its files."""
|
||||
staging = tmp_path / "staging"
|
||||
_add_to_mirror(staging, _NS_CMP_2)
|
||||
order: list[str] = []
|
||||
real_rename = component_mirror.rename_with_retry
|
||||
real_write = component_mirror.write_file
|
||||
|
||||
def recording_rename(src: Path, dst: Path, **kwargs) -> None:
|
||||
order.append(src.name)
|
||||
real_rename(src, dst, **kwargs)
|
||||
|
||||
def recording_write(path: Path, text: str) -> None:
|
||||
order.append(path.name)
|
||||
real_write(path, text)
|
||||
|
||||
with (
|
||||
patch.object(component_mirror, "rename_with_retry", recording_rename),
|
||||
patch.object(component_mirror, "write_file", recording_write),
|
||||
):
|
||||
component_mirror._promote(staging, tmp_path / "mirror")
|
||||
|
||||
assert order[-1] == "cmp.json"
|
||||
assert set(order[:-1]) == {"CHECKSUMS.json", "ns__cmp-v2.0.0.zip"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# component_mirror_env
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_component_mirror_env_sets_file_url(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("IDF_COMPONENT_LOCAL_STORAGE_URL", raising=False)
|
||||
monkeypatch.delenv("IDF_COMPONENT_CHECK_NEW_VERSION", raising=False)
|
||||
env = component_mirror.component_mirror_env()
|
||||
mirror = component_mirror.get_mirror_path()
|
||||
assert env["IDF_COMPONENT_LOCAL_STORAGE_URL"] == mirror.as_uri()
|
||||
assert env["IDF_COMPONENT_CHECK_NEW_VERSION"] == "0"
|
||||
assert mirror.is_dir()
|
||||
|
||||
|
||||
def test_component_mirror_env_preserves_user_values(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A user local storage list keeps precedence; their new-version choice wins."""
|
||||
monkeypatch.setenv("IDF_COMPONENT_LOCAL_STORAGE_URL", "file:///user/mirror")
|
||||
monkeypatch.setenv("IDF_COMPONENT_CHECK_NEW_VERSION", "1")
|
||||
env = component_mirror.component_mirror_env()
|
||||
mirror_uri = component_mirror.get_mirror_path().as_uri()
|
||||
assert env["IDF_COMPONENT_LOCAL_STORAGE_URL"] == f"file:///user/mirror;{mirror_uri}"
|
||||
assert "IDF_COMPONENT_CHECK_NEW_VERSION" not in env
|
||||
|
||||
|
||||
def test_component_mirror_env_unwritable_cache(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A read-only cache disables the feature with a warning, not a failure."""
|
||||
with patch.object(Path, "mkdir", side_effect=OSError("read-only")):
|
||||
assert component_mirror.component_mirror_env() == {}
|
||||
assert "Component mirror unavailable" in caplog.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sync_component_mirror
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_sync(
|
||||
project_dir: Path,
|
||||
*,
|
||||
returncode: int = 0,
|
||||
stderr: str = "",
|
||||
side_effect: Exception | Callable | None = None,
|
||||
get_python=lambda: "/penv/python",
|
||||
get_env=lambda: {"PATH": "/penv"},
|
||||
) -> tuple[bool, MagicMock]:
|
||||
with patch.object(
|
||||
component_mirror.subprocess,
|
||||
"run",
|
||||
return_value=subprocess.CompletedProcess([], returncode, "", stderr),
|
||||
side_effect=side_effect,
|
||||
) as mock_run:
|
||||
ok = component_mirror.sync_component_mirror(
|
||||
project_dir / "dependencies.lock",
|
||||
project_dir / "src" / "idf_component.yml",
|
||||
get_python,
|
||||
get_env,
|
||||
)
|
||||
return ok, mock_run
|
||||
|
||||
|
||||
def _fake_registry_sync(returncode: int = 0):
|
||||
"""A subprocess.run stand-in that lays files out like `registry sync`."""
|
||||
|
||||
def run(cmd, **kwargs) -> subprocess.CompletedProcess:
|
||||
_add_to_mirror(Path(cmd[-1]), _NS_CMP_2)
|
||||
return subprocess.CompletedProcess(cmd, returncode, "", "sync failed")
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def _assert_sync_lock_released() -> None:
|
||||
lock = FileLock(str(component_mirror.get_mirror_path() / ".sync.lock"))
|
||||
lock.acquire(blocking=False)
|
||||
lock.release()
|
||||
|
||||
|
||||
def test_sync_runs_the_manager_for_missing_deps(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
_write_lock(tmp_path)
|
||||
ok, mock_run = _run_sync(tmp_path)
|
||||
# The stub stages nothing, so the post-promote recheck reports failure.
|
||||
assert not ok
|
||||
assert "uncovered" in caplog.text
|
||||
mirror = component_mirror.get_mirror_path()
|
||||
# The whole set goes in one invocation: one manager startup.
|
||||
mock_run.assert_called_once()
|
||||
assert mock_run.call_args.args[0] == [
|
||||
"/penv/python",
|
||||
"-m",
|
||||
"idf_component_manager",
|
||||
"registry",
|
||||
"sync",
|
||||
"--resolution",
|
||||
"latest",
|
||||
"--component",
|
||||
"bblanchon/arduinojson==7.4.3",
|
||||
"--component",
|
||||
"espressif/mdns==1.12.0",
|
||||
str(mirror / ".staging"),
|
||||
]
|
||||
assert mock_run.call_args.kwargs["env"] == {"PATH": "/penv"}
|
||||
_assert_sync_lock_released()
|
||||
|
||||
|
||||
def test_sync_skips_when_covered(tmp_path: Path) -> None:
|
||||
"""A covered project resolves no environment and runs no subprocess."""
|
||||
_write_lock(tmp_path)
|
||||
mirror = component_mirror.get_mirror_path()
|
||||
_add_to_mirror(mirror, _MDNS)
|
||||
_add_to_mirror(mirror, _ARDUINOJSON)
|
||||
|
||||
def _boom() -> str:
|
||||
raise AssertionError("environment resolved for a covered mirror")
|
||||
|
||||
ok, mock_run = _run_sync(tmp_path, get_python=_boom, get_env=_boom)
|
||||
assert ok
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
def test_sync_failure_is_tolerated(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
_write_lock(tmp_path)
|
||||
ok, mock_run = _run_sync(tmp_path, returncode=1, stderr="boom")
|
||||
assert not ok
|
||||
mock_run.assert_called_once()
|
||||
assert "Could not mirror" in caplog.text
|
||||
_assert_sync_lock_released()
|
||||
|
||||
|
||||
def test_sync_subprocess_errors_are_tolerated(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
_write_lock(tmp_path)
|
||||
ok, _ = _run_sync(tmp_path, side_effect=OSError("no such file"))
|
||||
assert not ok
|
||||
assert "Could not mirror" in caplog.text
|
||||
_assert_sync_lock_released()
|
||||
|
||||
|
||||
def test_sync_refreshes_ranged_specs_only_before_a_fresh_solve(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Without a lock the batch also re-resolves ranged pins, as dev would;
|
||||
with a lock the ranged candidate is left alone."""
|
||||
_write_manifest(tmp_path)
|
||||
ok, mock_run = _run_sync(tmp_path)
|
||||
assert not ok # the stub stages nothing, so the recheck fails
|
||||
assert "espressif/ranged^1.2.0" in mock_run.call_args.args[0]
|
||||
|
||||
_write_lock(tmp_path)
|
||||
ok, mock_run = _run_sync(tmp_path)
|
||||
assert not ok
|
||||
assert "espressif/ranged^1.2.0" not in mock_run.call_args.args[0]
|
||||
|
||||
|
||||
def test_sync_ranged_refresh_runs_with_everything_else_covered(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A covered build still refreshes its ranged pins when the lock is
|
||||
gone, in one invocation."""
|
||||
manifest = _write_manifest(tmp_path)
|
||||
mirror = component_mirror.get_mirror_path()
|
||||
for dep in component_mirror.parse_manifest_service_deps(manifest):
|
||||
_add_to_mirror(mirror, dep)
|
||||
ok, mock_run = _run_sync(tmp_path)
|
||||
assert ok
|
||||
mock_run.assert_called_once()
|
||||
assert "espressif/ranged^1.2.0" in mock_run.call_args.args[0]
|
||||
|
||||
|
||||
def test_sync_timeout_retries_ranged_specs_too(tmp_path: Path) -> None:
|
||||
"""The fallback covers refresh specs, not only the exact pins."""
|
||||
_write_manifest(
|
||||
tmp_path,
|
||||
"dependencies:\n ns/one:\n version: ^1.0\n ns/two:\n version: ^2.0\n",
|
||||
)
|
||||
effects = [
|
||||
subprocess.TimeoutExpired(cmd=[], timeout=120),
|
||||
subprocess.CompletedProcess([], 0, "", ""),
|
||||
subprocess.CompletedProcess([], 0, "", ""),
|
||||
]
|
||||
ok, mock_run = _run_sync(tmp_path, side_effect=effects)
|
||||
assert ok
|
||||
retried = [call.args[0][-2] for call in mock_run.call_args_list[1:]]
|
||||
assert retried == ["ns/one^1.0", "ns/two^2.0"]
|
||||
|
||||
|
||||
def test_sync_timeout_falls_back_to_one_component_at_a_time(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A timed-out batch retries per component, so the ones that finish
|
||||
stay promoted and later runs fetch only the rest."""
|
||||
_write_lock(tmp_path)
|
||||
|
||||
def slow_sync(cmd, **kwargs):
|
||||
specs = [arg for arg in cmd if "==" in arg]
|
||||
if specs != ["bblanchon/arduinojson==7.4.3"]:
|
||||
# The batch, and later the mdns retry, exceed the timeout.
|
||||
raise subprocess.TimeoutExpired(cmd=cmd, timeout=120)
|
||||
namespace, _, rest = specs[0].partition("/")
|
||||
name, _, version = rest.partition("==")
|
||||
_add_to_mirror(
|
||||
Path(cmd[-1]), component_mirror.ServiceDep(namespace, name, version)
|
||||
)
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
ok, mock_run = _run_sync(tmp_path, side_effect=slow_sync)
|
||||
assert not ok
|
||||
assert mock_run.call_count == 3 # batch, arduinojson, mdns
|
||||
assert "retrying one component" in caplog.text
|
||||
assert "kept 1 of 2" in caplog.text
|
||||
mirror = component_mirror.get_mirror_path()
|
||||
assert component_mirror.missing_deps(mirror, [_ARDUINOJSON, _MDNS]) == [_MDNS]
|
||||
assert not (mirror / ".staging").exists()
|
||||
_assert_sync_lock_released()
|
||||
|
||||
|
||||
def test_sync_timeout_then_per_component_completes(tmp_path: Path) -> None:
|
||||
"""When every component fits on its own, the fallback finishes the job."""
|
||||
_write_lock(tmp_path)
|
||||
|
||||
def slow_sync(cmd, **kwargs):
|
||||
specs = [arg for arg in cmd if "==" in arg]
|
||||
if len(specs) > 1:
|
||||
raise subprocess.TimeoutExpired(cmd=cmd, timeout=120)
|
||||
namespace, _, rest = specs[0].partition("/")
|
||||
name, _, version = rest.partition("==")
|
||||
_add_to_mirror(
|
||||
Path(cmd[-1]), component_mirror.ServiceDep(namespace, name, version)
|
||||
)
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
ok, mock_run = _run_sync(tmp_path, side_effect=slow_sync)
|
||||
assert ok
|
||||
assert mock_run.call_count == 3 # batch, then one call per component
|
||||
mirror = component_mirror.get_mirror_path()
|
||||
assert component_mirror.missing_deps(mirror, [_ARDUINOJSON, _MDNS]) == []
|
||||
|
||||
|
||||
def test_sync_timeout_then_component_error_stops(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A hard error in the fallback loop stops it like any other failure."""
|
||||
_write_lock(tmp_path)
|
||||
effects = [
|
||||
subprocess.TimeoutExpired(cmd=[], timeout=120),
|
||||
subprocess.CompletedProcess([], 1, "", "boom"),
|
||||
]
|
||||
ok, mock_run = _run_sync(tmp_path, side_effect=effects)
|
||||
assert not ok
|
||||
assert mock_run.call_count == 2
|
||||
assert "Could not mirror" in caplog.text
|
||||
_assert_sync_lock_released()
|
||||
|
||||
|
||||
def test_sync_unexpected_error_logs_a_traceback(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An operational failure warns in one line; a bug keeps its traceback."""
|
||||
_write_lock(tmp_path)
|
||||
with patch.object(component_mirror, "_promote", side_effect=KeyError("url")):
|
||||
ok, _ = _run_sync(tmp_path)
|
||||
assert not ok
|
||||
record = next(r for r in caplog.records if "Could not mirror" in r.message)
|
||||
assert record.exc_info
|
||||
|
||||
|
||||
def test_sync_single_component_timeout_is_not_retried(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""With one missing component the batch was already the retry; a build
|
||||
pays the timeout once."""
|
||||
_write_lock(tmp_path, _ns_cmp_lock("2.0.0"))
|
||||
ok, mock_run = _run_sync(
|
||||
tmp_path, side_effect=subprocess.TimeoutExpired(cmd=[], timeout=120)
|
||||
)
|
||||
assert not ok
|
||||
mock_run.assert_called_once()
|
||||
assert "kept 0 of 1" in caplog.text
|
||||
_assert_sync_lock_released()
|
||||
|
||||
|
||||
def test_sync_environment_resolution_failure_is_tolerated(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
_write_lock(tmp_path)
|
||||
|
||||
def _boom() -> str:
|
||||
raise EsphomeError("python not found")
|
||||
|
||||
ok, mock_run = _run_sync(tmp_path, get_python=_boom)
|
||||
assert not ok
|
||||
mock_run.assert_not_called()
|
||||
assert "Could not mirror" in caplog.text
|
||||
|
||||
|
||||
def test_sync_skips_when_another_process_holds_the_lock(tmp_path: Path) -> None:
|
||||
_write_lock(tmp_path)
|
||||
mirror = component_mirror.get_mirror_path()
|
||||
mirror.mkdir(parents=True)
|
||||
held = FileLock(str(mirror / ".sync.lock"))
|
||||
held.acquire(blocking=False)
|
||||
try:
|
||||
ok, mock_run = _run_sync(tmp_path)
|
||||
finally:
|
||||
held.release()
|
||||
assert ok
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
def test_sync_ignores_a_leftover_lock_file(tmp_path: Path) -> None:
|
||||
"""A lock file from a dead process does not block: the OS lock is gone."""
|
||||
_write_lock(tmp_path)
|
||||
mirror = component_mirror.get_mirror_path()
|
||||
mirror.mkdir(parents=True)
|
||||
(mirror / ".sync.lock").touch()
|
||||
_, mock_run = _run_sync(tmp_path)
|
||||
mock_run.assert_called() # the dead lock did not block the sync
|
||||
|
||||
|
||||
def test_sync_lock_oserror_is_a_failure(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A broken cache is not contention; the retry guard should apply."""
|
||||
_write_lock(tmp_path)
|
||||
with patch("filelock.FileLock.acquire", side_effect=PermissionError("ro")):
|
||||
ok, mock_run = _run_sync(tmp_path)
|
||||
assert not ok
|
||||
mock_run.assert_not_called()
|
||||
assert "Could not lock" in caplog.text
|
||||
|
||||
|
||||
def test_sync_undeletable_staging_is_a_failure(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An undeletable staging tree aborts the sync; stale files must
|
||||
never be promoted."""
|
||||
_write_lock(tmp_path)
|
||||
with patch.object(component_mirror, "rmtree", side_effect=OSError("stuck staging")):
|
||||
ok, mock_run = _run_sync(tmp_path)
|
||||
assert not ok
|
||||
mock_run.assert_not_called()
|
||||
assert "Could not mirror" in caplog.text
|
||||
_assert_sync_lock_released()
|
||||
|
||||
|
||||
def test_sync_promotes_staged_files_and_merges_the_index(tmp_path: Path) -> None:
|
||||
"""New files land atomically and existing versions survive the merge."""
|
||||
mirror = component_mirror.get_mirror_path()
|
||||
_add_to_mirror(mirror, _NS_CMP_1)
|
||||
_write_lock(tmp_path, _ns_cmp_lock("2.0.0"))
|
||||
|
||||
ok, _ = _run_sync(tmp_path, side_effect=_fake_registry_sync())
|
||||
|
||||
assert ok
|
||||
doc = json.loads((mirror / "components" / "ns" / "cmp.json").read_text())
|
||||
assert {entry["version"] for entry in doc["versions"]} == {"1.0.0", "2.0.0"}
|
||||
assert (mirror / "components/ns/cmp/2.0.0/ns__cmp-v2.0.0.zip").is_file()
|
||||
assert (mirror / "components/ns/cmp/1.0.0/ns__cmp-v1.0.0.zip").is_file()
|
||||
assert not (mirror / ".staging").exists()
|
||||
|
||||
|
||||
def test_sync_failure_leaves_no_staging_behind(tmp_path: Path) -> None:
|
||||
"""A failed sync promotes nothing and removes its staging directory."""
|
||||
_write_lock(tmp_path, _ns_cmp_lock("2.0.0"))
|
||||
|
||||
ok, _ = _run_sync(tmp_path, side_effect=_fake_registry_sync(returncode=1))
|
||||
|
||||
assert not ok
|
||||
mirror = component_mirror.get_mirror_path()
|
||||
assert not (mirror / ".staging").exists()
|
||||
assert not (mirror / "components" / "ns").exists()
|
||||
|
||||
|
||||
def test_sync_failed_index_publish_keeps_the_live_index(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A failed publish keeps the live index; a later run retries."""
|
||||
mirror = component_mirror.get_mirror_path()
|
||||
_add_to_mirror(mirror, _NS_CMP_1)
|
||||
_write_lock(tmp_path, _ns_cmp_lock("2.0.0"))
|
||||
|
||||
with patch.object(
|
||||
component_mirror, "write_file", side_effect=EsphomeError("disk full")
|
||||
):
|
||||
ok, _ = _run_sync(tmp_path, side_effect=_fake_registry_sync())
|
||||
|
||||
assert not ok
|
||||
assert "Could not mirror" in caplog.text
|
||||
doc = json.loads((mirror / "components" / "ns" / "cmp.json").read_text())
|
||||
assert {entry["version"] for entry in doc["versions"]} == {"1.0.0"}
|
||||
_assert_sync_lock_released()
|
||||
@@ -215,6 +215,38 @@ def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None:
|
||||
assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep)
|
||||
|
||||
|
||||
def test_get_idf_env_serves_the_component_mirror(setup_core: Path) -> None:
|
||||
"""The mirror env rides the managed-IDF branch, next to its sync gate."""
|
||||
toolchain._cache().env.clear()
|
||||
with (
|
||||
patch.object(
|
||||
toolchain,
|
||||
"get_framework_env",
|
||||
return_value={"PATH": "/penv"},
|
||||
),
|
||||
patch.object(toolchain, "_get_esphome_esp_idf_paths", return_value=((), {})),
|
||||
patch.object(
|
||||
toolchain,
|
||||
"component_mirror_env",
|
||||
return_value={"IDF_COMPONENT_LOCAL_STORAGE_URL": "file:///mirror"},
|
||||
),
|
||||
):
|
||||
env = toolchain._get_idf_env(version="5.5.4")
|
||||
assert env["IDF_COMPONENT_LOCAL_STORAGE_URL"] == "file:///mirror"
|
||||
|
||||
|
||||
def test_get_idf_env_user_idf_skips_the_mirror(setup_core: Path) -> None:
|
||||
"""A user-managed IDF gets neither the framework env nor the mirror."""
|
||||
toolchain._cache().env.clear()
|
||||
with (
|
||||
patch.dict(os.environ, {"IDF_PATH": str(setup_core)}),
|
||||
patch.object(toolchain, "component_mirror_env") as mock_env,
|
||||
):
|
||||
env = toolchain._get_idf_env(version="5.5.4")
|
||||
mock_env.assert_not_called()
|
||||
assert "IDF_COMPONENT_LOCAL_STORAGE_URL" not in env
|
||||
|
||||
|
||||
def test_get_idf_env_pops_inherited_pythonpath(setup_core: Path) -> None:
|
||||
"""A PYTHONPATH from the parent environment must not reach idf.py.
|
||||
|
||||
@@ -362,6 +394,73 @@ def test_run_compile_discovery_without_cmakecache(setup_core: Path) -> None:
|
||||
assert not CORE.relative_build_path("build/CMakeCache.txt").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cmake_rc", "expected"),
|
||||
[(0, ["sync", "cmake", "sync"]), (1, ["sync", "cmake"])],
|
||||
)
|
||||
def test_run_reconfigure_syncs_mirror_around_the_configure(
|
||||
setup_core: Path, cmake_rc: int, expected: list[str]
|
||||
) -> None:
|
||||
"""Every configure path syncs: before, so the solve installs from the
|
||||
mirror; after a success, so solver-added lock entries are mirrored."""
|
||||
_setup_build(setup_core)
|
||||
calls: list[str] = []
|
||||
|
||||
with (
|
||||
_fake_tools() as mock_run,
|
||||
patch.object(
|
||||
toolchain,
|
||||
"_sync_component_mirror",
|
||||
side_effect=lambda: calls.append("sync"),
|
||||
),
|
||||
# The real hint printer resolves an IDF install (or downloads one).
|
||||
patch.object(toolchain, "_print_hints"),
|
||||
):
|
||||
mock_run.side_effect = lambda *a, **k: calls.append("cmake") or cmake_rc
|
||||
assert toolchain.run_reconfigure() == cmake_rc
|
||||
|
||||
assert calls == expected
|
||||
|
||||
|
||||
def test_run_compile_syncs_mirror_when_up_to_date(setup_core: Path) -> None:
|
||||
"""No reconfigure still refreshes the mirror once for ninja-driven
|
||||
cmake re-runs."""
|
||||
_setup_build(setup_core)
|
||||
config = {CONF_ESPHOME: {}}
|
||||
|
||||
with (
|
||||
_up_to_date_compile(),
|
||||
patch.object(toolchain, "_sync_component_mirror") as mock_sync,
|
||||
):
|
||||
assert toolchain.run_compile(config, verbose=False) == 0
|
||||
|
||||
mock_sync.assert_called_once()
|
||||
|
||||
|
||||
def test_sync_component_mirror_skips_user_idf(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A user-managed IDF never gets the mirror env, so a sync is unread."""
|
||||
monkeypatch.setenv("IDF_PATH", "/opt/esp-idf")
|
||||
with patch.object(toolchain, "sync_component_mirror") as mock_sync:
|
||||
toolchain._sync_component_mirror()
|
||||
mock_sync.assert_not_called()
|
||||
|
||||
|
||||
def test_sync_component_mirror_failure_not_retried(
|
||||
setup_core: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""One failed attempt (e.g. offline) must not stall every later call."""
|
||||
monkeypatch.delenv("IDF_PATH", raising=False)
|
||||
_setup_build(setup_core)
|
||||
with patch.object(
|
||||
toolchain, "sync_component_mirror", return_value=False
|
||||
) as mock_sync:
|
||||
toolchain._sync_component_mirror()
|
||||
toolchain._sync_component_mirror()
|
||||
mock_sync.assert_called_once()
|
||||
|
||||
|
||||
def test_run_compile_reconfigures_after_full_write_outside_testing_mode(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
|
||||
@@ -27,7 +27,6 @@ from esphome.framework_helpers import (
|
||||
_7z_extract_all,
|
||||
_BatchDownloadProgress,
|
||||
_detect_archive_root,
|
||||
_rename_with_retry,
|
||||
_tar_extract_all,
|
||||
_zip_extract_all,
|
||||
archive_extract_all,
|
||||
@@ -40,6 +39,7 @@ from esphome.framework_helpers import (
|
||||
get_project_link_flags,
|
||||
get_python_env_executable_path,
|
||||
get_system_python_path,
|
||||
rename_with_retry,
|
||||
rmdir,
|
||||
run_batch_downloads,
|
||||
run_command,
|
||||
@@ -1013,7 +1013,7 @@ class TestDownloadWithResume:
|
||||
with (
|
||||
patch("requests.get", return_value=_mock_response(b"data")) as mock_get,
|
||||
patch(
|
||||
"esphome.framework_helpers._rename_with_retry",
|
||||
"esphome.framework_helpers.rename_with_retry",
|
||||
side_effect=[PermissionError("locked"), None],
|
||||
) as rename,
|
||||
):
|
||||
@@ -1030,7 +1030,7 @@ class TestDownloadWithResume:
|
||||
with (
|
||||
patch("requests.get", return_value=_mock_response(b"data")),
|
||||
patch(
|
||||
"esphome.framework_helpers._rename_with_retry",
|
||||
"esphome.framework_helpers.rename_with_retry",
|
||||
side_effect=PermissionError("locked"),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="after 1 attempts"),
|
||||
@@ -2019,7 +2019,7 @@ class TestZipExtractAllBranches:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _rename_with_retry
|
||||
# rename_with_retry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -2028,7 +2028,7 @@ class TestRenameWithRetry:
|
||||
src = tmp_path / "src.txt"
|
||||
src.write_text("data")
|
||||
dst = tmp_path / "dst.txt"
|
||||
_rename_with_retry(src, dst)
|
||||
rename_with_retry(src, dst)
|
||||
assert dst.read_text() == "data"
|
||||
assert not src.exists()
|
||||
|
||||
@@ -2050,7 +2050,7 @@ class TestRenameWithRetry:
|
||||
patch.object(Path, "rename", flaky_rename),
|
||||
patch("esphome.framework_helpers.time.sleep"),
|
||||
):
|
||||
_rename_with_retry(src, dst, attempts=3)
|
||||
rename_with_retry(src, dst, attempts=3)
|
||||
assert dst.read_text() == "data"
|
||||
|
||||
def test_raises_after_all_attempts_fail(self, tmp_path: Path) -> None:
|
||||
@@ -2062,14 +2062,14 @@ class TestRenameWithRetry:
|
||||
patch("esphome.framework_helpers.time.sleep"),
|
||||
pytest.raises(PermissionError),
|
||||
):
|
||||
_rename_with_retry(src, dst, attempts=3)
|
||||
rename_with_retry(src, dst, attempts=3)
|
||||
|
||||
def test_attempts_zero_is_noop(self, tmp_path: Path) -> None:
|
||||
"""Zero attempts means the for-loop body never runs; src is untouched."""
|
||||
src = tmp_path / "src.txt"
|
||||
src.write_text("data")
|
||||
dst = tmp_path / "dst.txt"
|
||||
_rename_with_retry(src, dst, attempts=0)
|
||||
rename_with_retry(src, dst, attempts=0)
|
||||
assert src.exists()
|
||||
assert not dst.exists()
|
||||
|
||||
|
||||
@@ -192,7 +192,9 @@ def test_native_upload_fast_path_does_not_import_heavy_modules(
|
||||
fixture_path,
|
||||
probe_env,
|
||||
"native_upload_fast_path.py",
|
||||
extra=("esphome.components.esp8266",),
|
||||
# The cache-hit modules guard the espidf toolchain import chain:
|
||||
# a top-level yaml import there would tax every upload subprocess.
|
||||
extra=("esphome.components.esp8266", *CACHE_HIT_HEAVY_MODULES),
|
||||
)
|
||||
assert not leaked, (
|
||||
f"native upload_using_esptool pulls in heavy modules: {leaked}. "
|
||||
|
||||
Reference in New Issue
Block a user