mirror of
https://github.com/esphome/esphome.git
synced 2026-10-04 10:09:13 +00:00
[espidf] Mirror registry components locally so configures skip the registry (#19949)
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
"""Local mirror of IDF component-registry packages.
|
||||
|
||||
The component manager contacts the registry on every cmake configure, even
|
||||
with an unchanged ``dependencies.lock``, but checks
|
||||
``IDF_COMPONENT_LOCAL_STORAGE_URL`` mirrors first and stops on a hit.
|
||||
Mirroring the pinned components with its own ``registry sync`` removes all
|
||||
registry traffic and makes builds work offline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
import zipfile
|
||||
|
||||
from esphome.build_helpers.tools_cache import IDF_TOOLS_CACHE, tools_cache_path
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.framework_helpers import rename_with_retry
|
||||
from esphome.helpers import rmtree, write_file
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator, Mapping
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Bump the name if the manager's storage layout ever changes incompatibly;
|
||||
# the stale directory is removed with the tools cache on clean-all.
|
||||
_MIRROR_DIR_NAME = "component_mirror"
|
||||
|
||||
_ENV_LOCAL_STORAGE_URL = "IDF_COMPONENT_LOCAL_STORAGE_URL"
|
||||
_ENV_CHECK_NEW_VERSION = "IDF_COMPONENT_CHECK_NEW_VERSION"
|
||||
|
||||
_DEFAULT_REGISTRY_URL = "https://components.espressif.com"
|
||||
# Everything a missing, unreadable or wrongly-shaped index file can raise.
|
||||
_BAD_INDEX_ERRORS = (OSError, ValueError, TypeError, KeyError, AttributeError)
|
||||
_SYNC_LOCK_NAME = ".sync.lock"
|
||||
_STAGING_DIR_NAME = ".staging"
|
||||
_SYNC_TIMEOUT_S = 120
|
||||
|
||||
|
||||
class ServiceDep(NamedTuple):
|
||||
"""A registry ("service") dependency pinned to an exact version."""
|
||||
|
||||
namespace: str
|
||||
name: str
|
||||
version: str
|
||||
|
||||
@property
|
||||
def spec(self) -> str:
|
||||
return f"{self.namespace}/{self.name}=={self.version}"
|
||||
|
||||
|
||||
def get_mirror_path() -> Path:
|
||||
"""The machine-global mirror directory."""
|
||||
return tools_cache_path(*IDF_TOOLS_CACHE) / _MIRROR_DIR_NAME
|
||||
|
||||
|
||||
def component_mirror_env() -> dict[str, str]:
|
||||
"""Environment additions that serve the mirror to the component manager.
|
||||
|
||||
A user local storage list keeps precedence; the new-version check (an
|
||||
extra solve whose answer exact pins make moot) is off unless user-set.
|
||||
"""
|
||||
mirror = get_mirror_path()
|
||||
try:
|
||||
mirror.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as err:
|
||||
# Offline support is quietly lost otherwise; say so.
|
||||
_LOGGER.warning("Component mirror unavailable at %s: %s", mirror, err)
|
||||
return {}
|
||||
_LOGGER.info("Serving IDF components from the local mirror at %s", mirror)
|
||||
local_storage = mirror.as_uri()
|
||||
if user_value := os.environ.get(_ENV_LOCAL_STORAGE_URL):
|
||||
local_storage = f"{user_value};{local_storage}"
|
||||
env = {_ENV_LOCAL_STORAGE_URL: local_storage}
|
||||
if _ENV_CHECK_NEW_VERSION not in os.environ:
|
||||
env[_ENV_CHECK_NEW_VERSION] = "0"
|
||||
return env
|
||||
|
||||
|
||||
def _load_yaml_dict(path: Path) -> dict | None:
|
||||
"""Read a small YAML mapping; missing file or bad content is None."""
|
||||
# Deferred: keeps pyyaml off the serial upload fast path.
|
||||
import yaml
|
||||
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, UnicodeDecodeError) as err:
|
||||
_LOGGER.warning("Could not read %s: %s", path, err)
|
||||
return None
|
||||
try:
|
||||
data = yaml.safe_load(text)
|
||||
except yaml.YAMLError as err:
|
||||
_LOGGER.warning("Could not parse %s: %s", path, err)
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _iter_deps(path: Path) -> Iterator[tuple[str, str, dict]]:
|
||||
"""(namespace, name, entry) for each namespaced dependency in a YAML file."""
|
||||
data = _load_yaml_dict(path)
|
||||
deps = data.get("dependencies") if data else None
|
||||
if not isinstance(deps, dict):
|
||||
return
|
||||
for key, entry in deps.items():
|
||||
if isinstance(key, str) and "/" in key and isinstance(entry, dict):
|
||||
# The registry stores lowercase paths; match a mixed-case key.
|
||||
namespace, _, name = key.lower().partition("/")
|
||||
yield namespace, name, entry
|
||||
|
||||
|
||||
def parse_lock_service_deps(lock_path: Path) -> list[ServiceDep]:
|
||||
"""Pinned default-registry dependencies from a dependencies.lock.
|
||||
|
||||
Git, local and idf sources cannot be mirrored; non-default registries
|
||||
are left to the manager.
|
||||
"""
|
||||
deps: list[ServiceDep] = []
|
||||
for namespace, name, entry in _iter_deps(lock_path):
|
||||
source = entry.get("source")
|
||||
if not isinstance(source, dict) or source.get("type") != "service":
|
||||
continue
|
||||
registry_url = str(source.get("registry_url") or _DEFAULT_REGISTRY_URL)
|
||||
if registry_url.rstrip("/") != _DEFAULT_REGISTRY_URL:
|
||||
_LOGGER.debug("Not mirroring %s/%s: non-default registry", namespace, name)
|
||||
continue
|
||||
if isinstance(version := entry.get("version"), str):
|
||||
deps.append(ServiceDep(namespace, name, version))
|
||||
return deps
|
||||
|
||||
|
||||
def parse_manifest_service_deps(manifest_path: Path) -> list[ServiceDep]:
|
||||
"""Exactly-pinned registry dependencies from an idf_component.yml.
|
||||
|
||||
The manifest exists before the first configure, so a fresh solve can
|
||||
install from the mirror; range specs are left to the solver.
|
||||
"""
|
||||
deps: list[ServiceDep] = []
|
||||
for namespace, name, entry in _iter_deps(manifest_path):
|
||||
if entry.keys() != {"version"} or not isinstance(
|
||||
version := entry["version"], str
|
||||
):
|
||||
continue
|
||||
# The YAML shorthand keeps its operator: "==1.2.3" is an exact pin.
|
||||
version = version.removeprefix("==")
|
||||
# Exact versions start with a digit ("1.12.0", "1.3.3~1"); range
|
||||
# operators are prefixes and wildcards contain "*".
|
||||
if version[:1].isdigit() and "*" not in version:
|
||||
deps.append(ServiceDep(namespace, name, version))
|
||||
return deps
|
||||
|
||||
|
||||
def parse_manifest_ranged_specs(manifest_path: Path) -> list[str]:
|
||||
"""Compote specs for the manifest's range-pinned entries.
|
||||
|
||||
Refreshed before every fresh solve, as a range resolves on dev; the
|
||||
previous candidate stays the offline fallback.
|
||||
"""
|
||||
specs: list[str] = []
|
||||
for namespace, name, entry in _iter_deps(manifest_path):
|
||||
if entry.keys() != {"version"} or not isinstance(
|
||||
version := entry["version"], str
|
||||
):
|
||||
continue
|
||||
# [:1] alone would match "": the empty string is in every string.
|
||||
if version.startswith(("^", "~", "<", ">", "!")) or (
|
||||
version.startswith("=") and not version.startswith("==")
|
||||
):
|
||||
specs.append(f"{namespace}/{name}{version}")
|
||||
return specs
|
||||
|
||||
|
||||
def project_service_deps(lock_path: Path, manifest_path: Path) -> list[ServiceDep]:
|
||||
"""The mirrorable dependencies of a build; the lock wins, the manifest
|
||||
covers the build whose lock has not been written yet."""
|
||||
deps = parse_lock_service_deps(lock_path)
|
||||
seen = {(dep.namespace, dep.name) for dep in deps}
|
||||
for dep in parse_manifest_service_deps(manifest_path):
|
||||
if (dep.namespace, dep.name) not in seen:
|
||||
deps.append(dep)
|
||||
return deps
|
||||
|
||||
|
||||
def _mirror_has(mirror: Path, dep: ServiceDep) -> bool:
|
||||
"""Whether the mirror holds the dependency's index entry and files.
|
||||
|
||||
The manager fetches every file the entry names with no registry
|
||||
fallback once the version is found locally.
|
||||
"""
|
||||
json_path = mirror / "components" / dep.namespace / f"{dep.name}.json"
|
||||
try:
|
||||
for entry in json.loads(json_path.read_text(encoding="utf-8"))["versions"]:
|
||||
if entry.get("version") == dep.version:
|
||||
checksums = entry.get("checksums")
|
||||
# is_zipfile rejects an archive torn by a crash or an
|
||||
# old race, so the next sync replaces it (self-heal).
|
||||
archive = mirror / entry["url"]
|
||||
if (
|
||||
archive.suffix == ".zip"
|
||||
and archive.is_file()
|
||||
and not (zipfile.is_zipfile(archive))
|
||||
):
|
||||
# Deleted, the manager falls back to the registry even
|
||||
# when the heal sync cannot run; kept, it installs the
|
||||
# torn file and the configure fails.
|
||||
with suppress(OSError):
|
||||
archive.unlink()
|
||||
return False
|
||||
return archive.is_file() and (
|
||||
not checksums or (mirror / checksums).is_file()
|
||||
)
|
||||
except _BAD_INDEX_ERRORS:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def missing_deps(mirror: Path, deps: list[ServiceDep]) -> list[ServiceDep]:
|
||||
return [dep for dep in deps if not _mirror_has(mirror, dep)]
|
||||
|
||||
|
||||
def _read_versions(path: Path) -> list[dict]:
|
||||
"""The version entries of an index; [] when missing or corrupt, so
|
||||
publishing heals it. An I/O error raises: the content may be valid,
|
||||
so the caller retries later instead of dropping its versions."""
|
||||
try:
|
||||
entries = json.loads(path.read_text(encoding="utf-8"))["versions"]
|
||||
return [entry for entry in entries if isinstance(entry, dict)]
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except (ValueError, TypeError, KeyError) as err:
|
||||
_LOGGER.debug("Ignoring the corrupt index %s: %s", path, err)
|
||||
return []
|
||||
|
||||
|
||||
def _publish_index(src: Path, dst: Path) -> None:
|
||||
"""Publish the staged index merged with the live one, atomically.
|
||||
|
||||
The staged index lists only the versions this sync fetched; the
|
||||
atomic write keeps the live index intact when it fails, and raises.
|
||||
"""
|
||||
staged = json.loads(src.read_text(encoding="utf-8"))
|
||||
versions = [entry for entry in staged["versions"] if isinstance(entry, dict)]
|
||||
known = {entry.get("version") for entry in versions}
|
||||
staged["versions"] = versions + [
|
||||
entry for entry in _read_versions(dst) if entry.get("version") not in known
|
||||
]
|
||||
write_file(dst, json.dumps(staged))
|
||||
|
||||
|
||||
def _is_component_index(rel: Path) -> bool:
|
||||
return (
|
||||
rel.parts[0] == "components" and len(rel.parts) == 3 and rel.suffix == ".json"
|
||||
)
|
||||
|
||||
|
||||
def _promote(staging: Path, mirror: Path) -> None:
|
||||
"""Move the synced files into the mirror, atomically per file.
|
||||
|
||||
Indexes go last, so a concurrent configure never reads a version
|
||||
entry whose files have not landed yet.
|
||||
"""
|
||||
files = sorted(
|
||||
(path for path in staging.rglob("*") if path.is_file()),
|
||||
key=lambda path: (_is_component_index(path.relative_to(staging)), path),
|
||||
)
|
||||
for src in files:
|
||||
rel = src.relative_to(staging)
|
||||
dst = mirror / rel
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
if _is_component_index(rel):
|
||||
_publish_index(src, dst)
|
||||
else:
|
||||
rename_with_retry(src, dst, overwrite=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SyncRun:
|
||||
"""Tools and progress of one registry sync."""
|
||||
|
||||
python: str
|
||||
env: dict[str, str]
|
||||
staging: Path
|
||||
mirror: Path
|
||||
synced: int = field(default=0, init=False)
|
||||
|
||||
def _attempt(self, specs: list[str]) -> bool:
|
||||
"""One registry sync invocation; promotes its staging on success."""
|
||||
# Raises on an undeletable tree; never promote stale files.
|
||||
rmtree(self.staging)
|
||||
cmd = [self.python, "-m", "idf_component_manager", "registry", "sync"]
|
||||
# latest: the default "all" follows ranged transitive specs and
|
||||
# syncs every matching version.
|
||||
cmd += ["--resolution", "latest"]
|
||||
for spec in specs:
|
||||
cmd += ["--component", spec]
|
||||
cmd.append(str(self.staging))
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
env=self.env,
|
||||
capture_output=True,
|
||||
# Not text=True: the locale codec can raise UnicodeDecodeError.
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=_SYNC_TIMEOUT_S,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
tail = "\n".join((result.stderr or result.stdout).strip().splitlines()[-5:])
|
||||
_LOGGER.warning(
|
||||
"Could not mirror %s (exit %d):\n%s",
|
||||
", ".join(specs),
|
||||
result.returncode,
|
||||
tail,
|
||||
)
|
||||
return False
|
||||
_promote(self.staging, self.mirror)
|
||||
return True
|
||||
|
||||
def sync(self, specs: list[str]) -> bool:
|
||||
"""The whole set in one invocation, per component after a timeout."""
|
||||
try:
|
||||
# One invocation for the whole set: one manager startup, which
|
||||
# matters on slow hosts.
|
||||
if not self._attempt(specs):
|
||||
return False
|
||||
self.synced = len(specs)
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
if len(specs) == 1:
|
||||
raise
|
||||
_LOGGER.warning(
|
||||
"Mirroring timed out after %d s; retrying one component at a time",
|
||||
_SYNC_TIMEOUT_S,
|
||||
)
|
||||
# Per component the timeout bounds each one, finished ones stay
|
||||
# promoted, and the first failure stops the loop.
|
||||
for spec in specs:
|
||||
if not self._attempt([spec]):
|
||||
return False
|
||||
self.synced += 1
|
||||
return True
|
||||
|
||||
|
||||
def sync_component_mirror(
|
||||
lock_path: Path,
|
||||
manifest_path: Path,
|
||||
get_python: Callable[[], str],
|
||||
get_env: Callable[[], Mapping[str, str]],
|
||||
) -> bool:
|
||||
"""Mirror any of the build's registry components the mirror lacks.
|
||||
|
||||
Returns False on a failed attempt so callers can skip retrying this
|
||||
run; ``get_python``/``get_env`` are only called when a sync is needed.
|
||||
"""
|
||||
mirror = get_mirror_path()
|
||||
to_sync = missing_deps(mirror, project_service_deps(lock_path, manifest_path))
|
||||
# Before a fresh solve, ranged pins re-resolve as they would on dev.
|
||||
refresh = [] if lock_path.exists() else parse_manifest_ranged_specs(manifest_path)
|
||||
if not to_sync and not refresh:
|
||||
return True
|
||||
try:
|
||||
python = get_python()
|
||||
env = dict(get_env())
|
||||
mirror.mkdir(parents=True, exist_ok=True)
|
||||
except (OSError, EsphomeError) as err:
|
||||
_LOGGER.warning("Could not mirror IDF components: %s", err)
|
||||
return False
|
||||
# Staged: the manager writes in place, and a configure in another
|
||||
# process must never read a truncated file from the live mirror.
|
||||
staging = mirror / _STAGING_DIR_NAME
|
||||
# Lazy import, as in git.py: keeps filelock off the CLI startup path.
|
||||
from filelock import FileLock, Timeout
|
||||
|
||||
lock = FileLock(str(mirror / _SYNC_LOCK_NAME), fallback_to_soft=False)
|
||||
try:
|
||||
lock.acquire(blocking=False)
|
||||
except Timeout:
|
||||
# Another esphome process is already filling the shared mirror.
|
||||
_LOGGER.debug("Component mirror sync skipped: already in progress")
|
||||
return True
|
||||
except OSError as err:
|
||||
# A broken cache, not contention; the retry guard should apply.
|
||||
_LOGGER.warning("Could not lock the component mirror: %s", err)
|
||||
return False
|
||||
specs = [dep.spec for dep in to_sync] + refresh
|
||||
_LOGGER.info("Mirroring %d IDF component(s) for offline builds...", len(specs))
|
||||
run = _SyncRun(python, env, staging, mirror)
|
||||
try:
|
||||
if not run.sync(specs):
|
||||
return False
|
||||
if still := missing_deps(mirror, to_sync):
|
||||
# A name the registry spells differently syncs clean yet
|
||||
# covers nothing; retrying would loop forever.
|
||||
_LOGGER.warning(
|
||||
"Mirror sync left %d component(s) uncovered: %s",
|
||||
len(still),
|
||||
", ".join(dep.spec for dep in still),
|
||||
)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
_LOGGER.warning(
|
||||
"Mirroring timed out after %d s; kept %d of %d component(s)",
|
||||
_SYNC_TIMEOUT_S,
|
||||
run.synced,
|
||||
len(specs),
|
||||
)
|
||||
return False
|
||||
except (*_BAD_INDEX_ERRORS, EsphomeError, subprocess.SubprocessError) as err:
|
||||
# Includes a failed index publish; the live index is intact.
|
||||
# A shape error is a bug: keep its traceback.
|
||||
_LOGGER.warning(
|
||||
"Could not mirror IDF components: %s",
|
||||
err,
|
||||
exc_info=isinstance(err, (TypeError, KeyError, AttributeError)),
|
||||
)
|
||||
return False
|
||||
finally:
|
||||
# Cleanup only; a leftover tree is removed by the next attempt.
|
||||
with suppress(OSError):
|
||||
rmtree(staging)
|
||||
lock.release()
|
||||
_LOGGER.info("Mirrored %d IDF component(s) for offline builds", run.synced)
|
||||
return True
|
||||
@@ -24,6 +24,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.espidf import variant_to_idf_target
|
||||
from esphome.espidf.component_mirror import component_mirror_env, sync_component_mirror
|
||||
from esphome.espidf.framework import check_esp_idf_install, get_framework_env
|
||||
from esphome.espidf.size_summary import print_summary
|
||||
from esphome.helpers import add_git_ceiling_directory, get_bool_env, rmtree, write_file
|
||||
@@ -43,6 +44,7 @@ class _CacheData:
|
||||
cmake_output: dict[Path, str] = field(default_factory=dict)
|
||||
skip_bootloader: bool | None = None
|
||||
cmake_tools: dict[Path, dict[str, Path]] = field(default_factory=dict)
|
||||
mirror_sync_failed: bool = False
|
||||
|
||||
|
||||
def _cache() -> _CacheData:
|
||||
@@ -110,6 +112,11 @@ def _get_idf_path(version: str | None = None) -> Path | None:
|
||||
return Path(_get_esphome_esp_idf_paths(version)[0])
|
||||
|
||||
|
||||
def _esphome_manages_idf() -> bool:
|
||||
"""A checkout supplied through IDF_PATH is the user's, not ESPHome's."""
|
||||
return "IDF_PATH" not in os.environ
|
||||
|
||||
|
||||
def _get_idf_env(version: str | None = None) -> dict[str, str]:
|
||||
"""Get environment variables needed for ESP-IDF build."""
|
||||
version = version or _get_core_framework_version()
|
||||
@@ -120,10 +127,12 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]:
|
||||
env_cache[version].pop("PYTHONPATH", None)
|
||||
|
||||
# Use provided IDF framework if available
|
||||
if "IDF_PATH" not in os.environ:
|
||||
if _esphome_manages_idf():
|
||||
env_cache[version] |= get_framework_env(
|
||||
*_get_esphome_esp_idf_paths(version)
|
||||
)
|
||||
# Serve the component manager from the local registry mirror.
|
||||
env_cache[version] |= component_mirror_env()
|
||||
|
||||
# Cap git's repo search at the config directory so ESP-IDF's
|
||||
# `git describe` for the app version can't error out on an
|
||||
@@ -369,6 +378,8 @@ def run_reconfigure(verbose: bool = False) -> int:
|
||||
if (path := build_dir / stale).is_dir():
|
||||
rmtree(path)
|
||||
get_factory_firmware_path().unlink(missing_ok=True)
|
||||
# First, so the configure (even a first solve) installs from the mirror.
|
||||
_sync_component_mirror()
|
||||
cmd = [_get_idf_tool("cmake"), "-G", "Ninja"]
|
||||
if _idf_py().binary_dir_arg:
|
||||
cmd += ["-B", str(build_dir)]
|
||||
@@ -387,6 +398,9 @@ def run_reconfigure(verbose: bool = False) -> int:
|
||||
(build_dir / "CMakeCache.txt").unlink(missing_ok=True)
|
||||
_LOGGER.error("CMake configure failed with exit code %d", rc)
|
||||
_print_hints(log_path)
|
||||
return rc
|
||||
# Mirror what the solve added to the lock (transitive dependencies).
|
||||
_sync_component_mirror()
|
||||
return rc
|
||||
|
||||
|
||||
@@ -481,6 +495,24 @@ def _print_hints(log_path: Path) -> None:
|
||||
_LOGGER.warning("%s", hints)
|
||||
|
||||
|
||||
def _sync_component_mirror() -> None:
|
||||
"""Best-effort update of the local registry mirror; never fails the build."""
|
||||
if not _esphome_manages_idf():
|
||||
# _get_idf_env injects no mirror env, so nothing would read a sync.
|
||||
return
|
||||
cache = _cache()
|
||||
if cache.mirror_sync_failed:
|
||||
return
|
||||
if not sync_component_mirror(
|
||||
CORE.relative_build_path("dependencies.lock"),
|
||||
CORE.relative_build_path("src/idf_component.yml"),
|
||||
lambda: _get_idf_tool("python"),
|
||||
_get_idf_env,
|
||||
):
|
||||
# One failed attempt (e.g. offline) is enough per run.
|
||||
cache.mirror_sync_failed = True
|
||||
|
||||
|
||||
def _builtin_component_cache_path() -> Path | None:
|
||||
"""Cache file for this build's built-in component list.
|
||||
|
||||
@@ -491,7 +523,7 @@ def _builtin_component_cache_path() -> Path | None:
|
||||
of CONFIG_* options and only gate their sources on them. A checkout
|
||||
supplied through IDF_PATH is not managed by ESPHome and is never cached.
|
||||
"""
|
||||
if "IDF_PATH" in os.environ:
|
||||
if not _esphome_manages_idf():
|
||||
return None
|
||||
target = variant_to_idf_target(CORE.data[KEY_ESP32][KEY_VARIANT])
|
||||
excluded = CORE.cmake_args.get("EXCLUDE_COMPONENTS", "")
|
||||
@@ -793,6 +825,8 @@ def run_compile(config, verbose: bool) -> int:
|
||||
return rc
|
||||
else:
|
||||
_LOGGER.info("Build configuration is up to date")
|
||||
# Ninja can still re-run cmake on its own; keep the mirror current.
|
||||
_sync_component_mirror()
|
||||
|
||||
if not get_bool_env("ESPHOME_LDGEN_FULL_DEPS"):
|
||||
_warn_if_app_archive_mapped()
|
||||
|
||||
@@ -461,7 +461,7 @@ def _zip_extract_all(
|
||||
report(1)
|
||||
|
||||
|
||||
def _rename_with_retry(
|
||||
def rename_with_retry(
|
||||
src: Path, dst: Path, attempts: int = 5, overwrite: bool = False
|
||||
) -> None:
|
||||
"""Rename ``src`` to ``dst`` with backoff retries on Windows sharing violations.
|
||||
@@ -561,7 +561,7 @@ def _7z_extract_all(
|
||||
rmtree(dest)
|
||||
else:
|
||||
dest.unlink()
|
||||
_rename_with_retry(item, dest)
|
||||
rename_with_retry(item, dest)
|
||||
finally:
|
||||
# staging is created before the try, so it always exists here; the
|
||||
# guard is defensive cleanup and its False branch is unreachable.
|
||||
@@ -1193,7 +1193,7 @@ def download_with_resume(
|
||||
# retries fail, keep the verified part so the next attempt (or
|
||||
# run) only has to redo the rename, not the download.
|
||||
try:
|
||||
_rename_with_retry(part, dest, overwrite=True)
|
||||
rename_with_retry(part, dest, overwrite=True)
|
||||
except PermissionError as e:
|
||||
_LOGGER.debug("Could not move %s into place: %s", part, e)
|
||||
last_error = e
|
||||
|
||||
@@ -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