Merge branch 'esp8266-native-library-backend' into esp8266-native-build-spec

This commit is contained in:
J. Nick Koston
2026-08-22 15:25:48 -05:00
11 changed files with 638 additions and 126 deletions
+58 -24
View File
@@ -27,8 +27,10 @@ from esphome.platformio.library import (
LIBRARY_HEADER_SUFFIXES, LIBRARY_HEADER_SUFFIXES,
SRC_FILE_EXTENSIONS, SRC_FILE_EXTENSIONS,
ConvertedLibrary, ConvertedLibrary,
IncompatiblePlatform,
InvalidLibrary, InvalidLibrary,
LibraryBackend, LibraryBackend,
_url_or_none,
check_library_data, check_library_data,
collect_filtered_files, collect_filtered_files,
convert_libraries, convert_libraries,
@@ -219,18 +221,18 @@ def _collect_lib_sources(
len(dropped), len(dropped),
", ".join(sorted(dropped)), ", ".join(sorted(dropped)),
) )
if not lib.sources and not any( if (
Path(f).suffix.lower() in LIBRARY_HEADER_SUFFIXES for f in matched not lib.sources
and ("srcFilter" in build or "srcDir" in build)
and not any(Path(f).suffix.lower() in LIBRARY_HEADER_SUFFIXES for f in matched)
): ):
# Matched headers mean a header-only library; anything else with no # Matched headers mean a header-only library; a declared filter
# sources yields an empty archive that fails far away at link # matching nothing (or only inert files) is a manifest/tree problem.
if "srcFilter" in build or "srcDir" in build: # The truly empty tree raises via _assert_tree_has_code.
_LOGGER.warning( _LOGGER.warning(
"Library %s declares srcFilter/srcDir but no source files matched", "Library %s declares srcFilter/srcDir but no source files matched",
name, name,
) )
else:
_LOGGER.warning("Library %s has no sources or headers", name)
def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary: def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary:
@@ -284,18 +286,25 @@ def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary:
name, name,
) )
lib = _library_info(name, lib_dir, data) lib = _library_info(name, lib_dir, data)
if not lib.sources and not any( _assert_tree_has_code(
Path(p).suffix.lower() in LIBRARY_HEADER_SUFFIXES for p in walk_files(lib_dir) name,
): lib_dir,
# An empty or half-extracted bundled directory can never link; a "the framework install may be incomplete (run 'esphome clean-all')",
# warning would scroll away and resurface as undefined symbols )
raise EsphomeError(
f"Bundled library {name} has no sources or headers; the "
"framework install may be incomplete (run 'esphome clean-all')"
)
return lib return lib
def _assert_tree_has_code(name: str, root: Path, hint: str) -> None:
"""An empty or half-extracted tree can never link; fail by name (a
warning would scroll away and resurface as undefined symbols)."""
if not any(
Path(p).suffix in SRC_FILE_EXTENSIONS
or Path(p).suffix.lower() in LIBRARY_HEADER_SUFFIXES
for p in walk_files(root)
):
raise EsphomeError(f"Library {name} has no sources or headers; {hint}")
def _external_short_name(name: str) -> str: def _external_short_name(name: str) -> str:
"""The short library name of a requested spec. """The short library name of a requested spec.
@@ -411,6 +420,10 @@ def resolve_libraries(
continue continue
if name in bundled_names or is_lib_ignored(name, lib_ignore): if name in bundled_names or is_lib_ignored(name, lib_ignore):
continue continue
if _url_or_none(dep.get("version")) is not None:
# A URL names one specific source (the walk resolves it as
# git); the bundled copy must never be added on top
continue
if dep.get("owner") or not _provided(name): if dep.get("owner") or not _provided(name):
# Owner-less names in the framework tree prefer the bundled # Owner-less names in the framework tree prefer the bundled
# copy (PIO's process_dependencies); everything else resolves # copy (PIO's process_dependencies); everything else resolves
@@ -421,9 +434,19 @@ def resolve_libraries(
# mismatch; re-checking would warn twice # mismatch; re-checking would warn twice
check_library_data(dep, pio_platform, None) check_library_data(dep, pio_platform, None)
except InvalidLibrary as err: except InvalidLibrary as err:
# The shared walk already reported any non-platform cause; if isinstance(err, IncompatiblePlatform) or "version" not in dep:
# warning again here would read as two distinct failures # The platform skip is routine; the walk's version-less
_LOGGER.debug("Skip bundled candidate %s: %s", name, err) # filter already warned for other version-less causes
_LOGGER.debug("Skip bundled candidate %s: %s", name, err)
else:
# Versioned deps skip the walk's filter via provides();
# this is the only place the fault can be seen
_LOGGER.warning(
"Skipping bundled dependency %s of %s: %s",
name,
component.name,
err,
)
continue continue
# Deferred: a later-emitted library's manifest name may satisfy # Deferred: a later-emitted library's manifest name may satisfy
# this; adding now could double the archive # this; adding now could double the archive
@@ -433,6 +456,11 @@ def resolve_libraries(
apply_extra_script( apply_extra_script(
component, board_mcu=lambda: board_mcu, pio_platform=pio_platform component, board_mcu=lambda: board_mcu, pio_platform=pio_platform
) )
_assert_tree_has_code(
component.get_require_name(),
component.source_dir,
"the download may be incomplete (run 'esphome clean-all')",
)
if isinstance(manifest_name := component.data.get("name"), str): if isinstance(manifest_name := component.data.get("name"), str):
converted_manifest_names.add(manifest_name) converted_manifest_names.add(manifest_name)
converted.append( converted.append(
@@ -456,7 +484,13 @@ def resolve_libraries(
), ),
) )
for name in pending_bundled: for name in pending_bundled:
if name in converted_manifest_names or name in bundled_names: if name in converted_manifest_names:
# Exact manifest-name evidence: the converted library is this
# library, so the bundled copy would double the archive
_LOGGER.debug(
"Bundled %s suppressed by a converted library's manifest name",
name,
)
continue continue
bundled_names.add(name) bundled_names.add(name)
bundled.append(_bundled_library(framework_path, name)) bundled.append(_bundled_library(framework_path, name))
+21 -5
View File
@@ -65,16 +65,32 @@ def _run_ar(ar: str, archive: str, rspfile: str) -> int:
def _run_copy(src: str, dst: str) -> int: def _run_copy(src: str, dst: str) -> int:
shutil.copyfile(src, dst) try:
shutil.copyfile(src, dst)
except OSError:
# Never leave a partially written output (e.g. a firmware image)
Path(dst).unlink(missing_ok=True)
raise
return 0 return 0
# mode -> (handler, expected operand count); surplus argv means a
# mis-specified ninja rule and must error, not silently drop operands
_MODES = {"ar": (_run_ar, 3), "copy": (_run_copy, 2)}
def main() -> int: def main() -> int:
mode = sys.argv[1] mode = sys.argv[1]
if mode == "ar": if entry := _MODES.get(mode):
return _run_ar(*sys.argv[2:5]) handler, argc = entry
if mode == "copy": args = sys.argv[2:]
return _run_copy(*sys.argv[2:4]) if len(args) != argc:
print(
f"build_tool {mode}: expected {argc} arguments, got {len(args)}",
file=sys.stderr,
)
return 1
return handler(*args)
print(f"unknown build_tool mode: {mode}", file=sys.stderr) print(f"unknown build_tool mode: {mode}", file=sys.stderr)
return 1 return 1
+47 -9
View File
@@ -1,6 +1,7 @@
"""ESP-IDF framework tools for ESPHome.""" """ESP-IDF framework tools for ESPHome."""
from collections.abc import Callable from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from ctypes.util import find_library from ctypes.util import find_library
import json import json
import logging import logging
@@ -19,6 +20,7 @@ from esphome.build_helpers.ccache import (
from esphome.build_helpers.tools_cache import tools_cache_path from esphome.build_helpers.tools_cache import tools_cache_path
from esphome.core import Version from esphome.core import Version
from esphome.framework_helpers import ( from esphome.framework_helpers import (
BatchDownloadProgress,
PathType, PathType,
archive_extract_all, archive_extract_all,
create_venv, create_venv,
@@ -682,6 +684,12 @@ def _patch_tools_json_demote_unused_tools(framework_path: Path) -> None:
) )
# Tool archives are large (tens to hundreds of MB) and served by GitHub /
# dl.espressif.com; a few streams at once saturate most links without
# hammering the host. Smaller than external_files' 8: those are tiny files.
_PREFETCH_WORKERS = 4
def _prefetch_idf_tool_archives( def _prefetch_idf_tool_archives(
framework_path: Path, framework_path: Path,
targets_str: str, targets_str: str,
@@ -694,10 +702,10 @@ def _prefetch_idf_tool_archives(
which makes large archives effectively impossible to fetch on unstable which makes large archives effectively impossible to fetch on unstable
connections (#17703). This asks the framework's idf_tools (via connections (#17703). This asks the framework's idf_tools (via
``get_tool_downloads.py``) which archives the coming install needs, then ``get_tool_downloads.py``) which archives the coming install needs, then
downloads each into ``<IDF_TOOLS_PATH>/dist`` with downloads them into ``<IDF_TOOLS_PATH>/dist`` with
``download_with_resume``. The installer then finds the verified archives ``download_with_resume``, a few at a time under one combined progress
already in place ("file ... is already downloaded") and never touches the bar. The installer then finds the verified archives already in place
network. ("file ... is already downloaded") and never touches the network.
Strictly best-effort: any failure here just logs and returns, leaving Strictly best-effort: any failure here just logs and returns, leaving
``idf_tools.py install`` to download whatever is missing exactly as ``idf_tools.py install`` to download whatever is missing exactly as
@@ -724,21 +732,51 @@ def _prefetch_idf_tool_archives(
for entry in json.loads(stdout) for entry in json.loads(stdout)
if not (dist_path / entry["dest"]).is_file() if not (dist_path / entry["dest"]).is_file()
] ]
for index, entry in enumerate(entries, start=1): if not entries:
_LOGGER.info( return
"Downloading %s (%d/%d) ...", entry["name"], index, len(entries) _LOGGER.info(
) "Downloading %d ESP-IDF tool archive(s): %s",
len(entries),
", ".join(entry["name"] for entry in entries),
)
# tools.json always carries sizes; should one be missing the combined
# bar could not be trusted, so show no bar at all (per-file bars from
# several threads would interleave) rather than a wrong one.
sizes = [entry["size"] for entry in entries]
progress = BatchDownloadProgress(
"Downloading ESP-IDF tools", sum(sizes) if all(sizes) else 0
)
# Reported after the bar is done so the warnings do not land on
# its row; list.append is atomic under the GIL.
failures: list[tuple[str, Exception]] = []
def _download(entry: dict) -> None:
tracker = progress.tracker()
try: try:
download_with_resume( download_with_resume(
entry["url"], entry["url"],
dist_path / entry["dest"], dist_path / entry["dest"],
sha256=entry["sha256"], sha256=entry["sha256"],
size=entry["size"], size=entry["size"],
progress=tracker,
) )
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
# Keep prefetching the remaining archives; the installer # Keep prefetching the remaining archives; the installer
# will retry this one itself (without resume). # will retry this one itself (without resume).
_LOGGER.warning("Could not prefetch %s: %s", entry["name"], e) tracker(0)
failures.append((entry["name"], e))
ex = ThreadPoolExecutor(max_workers=min(_PREFETCH_WORKERS, len(entries)))
try:
for future in [ex.submit(_download, entry) for entry in entries]:
future.result()
finally:
# On Ctrl-C drop the queued archives instead of downloading them
# all before the process can exit; in-flight ones still finish.
ex.shutdown(wait=True, cancel_futures=True)
progress.done()
for name, e in failures:
_LOGGER.warning("Could not prefetch %s: %s", name, e)
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
# The installer downloads anything missing itself; never let the # The installer downloads anything missing itself; never let the
# prefetch become a new way for the install to fail. # prefetch become a new way for the install to fail.
+87 -30
View File
@@ -1,7 +1,7 @@
"""Generic toolchain installation helpers shared across framework implementations.""" """Generic toolchain installation helpers shared across framework implementations."""
from collections.abc import Iterable from collections.abc import Callable, Iterable
from contextlib import ExitStack, contextmanager from contextlib import ExitStack
import hashlib import hashlib
import io import io
import json import json
@@ -24,20 +24,6 @@ PathType = str | os.PathLike
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Concurrent downloads would interleave their progress bars; a worker thread
# suppresses its bar for the download it runs.
_PROGRESS_LOCAL = threading.local()
@contextmanager
def suppress_download_progress():
"""Silence the per-download progress bar in the current thread."""
_PROGRESS_LOCAL.disabled = True
try:
yield
finally:
_PROGRESS_LOCAL.disabled = False
# Attempts per mirror URL before falling through to the next mirror; only # Attempts per mirror URL before falling through to the next mirror; only
# mid-stream drops retry (resuming when the server gave a validator), # mid-stream drops retry (resuming when the server gave a validator),
@@ -735,7 +721,11 @@ def _response_validator(resp: "requests.Response") -> str | None:
def _stream_response_to_file( def _stream_response_to_file(
resp: "requests.Response", f: IO[bytes], offset: int, size: int | None = None resp: "requests.Response",
f: IO[bytes],
offset: int,
size: int | None = None,
progress: Callable[[int], None] | None = None,
) -> None: ) -> None:
"""Stream an open ``_open_ranged`` response body into ``f`` at ``offset``. """Stream an open ``_open_ranged`` response body into ``f`` at ``offset``.
@@ -743,25 +733,72 @@ def _stream_response_to_file(
(effective offset 0) discards the stale bytes. ``offset`` also seeds the (effective offset 0) discards the stale bytes. ``offset`` also seeds the
progress bar so a resumed download shows overall progress. ``size`` is progress bar so a resumed download shows overall progress. ``size`` is
the known full file size; when None it is derived from the response's the known full file size; when None it is derived from the response's
content-length, and without either there is no progress bar. content-length, and without either there is no progress bar. With
``progress`` set, no bar is drawn here; the callback gets the absolute
byte count, seeded with ``offset`` and then after each chunk.
""" """
f.seek(offset) f.seek(offset)
f.truncate(offset) f.truncate(offset)
total_size = size or offset + _content_length(resp) total_size = size or offset + _content_length(resp)
downloaded = offset downloaded = offset
progress = ( own_bar: ProgressBar | None = None
ProgressBar("Downloading") if progress is None:
if total_size > 0 and not getattr(_PROGRESS_LOCAL, "disabled", False) own_bar = ProgressBar("Downloading") if total_size > 0 else None
else None progress = (
) (lambda done: own_bar.update(done / total_size))
if own_bar
else (lambda _: None)
)
progress(downloaded)
for chunk in resp.iter_content(chunk_size=256 * 1024): for chunk in resp.iter_content(chunk_size=256 * 1024):
if chunk: if chunk:
f.write(chunk) f.write(chunk)
downloaded += len(chunk) downloaded += len(chunk)
if progress is not None: progress(downloaded)
progress.update(downloaded / total_size) if own_bar is not None:
if progress is not None: own_bar.update(1)
progress.update(1)
class BatchDownloadProgress:
"""One progress bar across several concurrent ``download_with_resume`` calls.
Each ``tracker()`` is a ``progress`` callback for one download; it reports
that file's absolute byte count and the bar shows the sum over ``total``.
The lock also serialises the bar's stderr writes, so worker threads never
interleave frames. With an unknown ``total`` (0) nothing is drawn. Call
``done()`` once every download has finished (or failed) so a bar that
never reached 100% still ends its line before the next log message.
"""
def __init__(self, header: str, total: int) -> None:
self._bar = ProgressBar(header) if total > 0 else None
self._total = total
self._sum = 0
self._lock = threading.Lock()
def tracker(self) -> Callable[[int], None]:
last = 0
def update(done: int) -> None:
nonlocal last
if self._bar is None:
return
with self._lock:
self._sum += done - last
last = done
self._bar.update(min(self._sum / self._total, 1))
return update
def done(self) -> None:
# Nothing to end unless a frame was drawn and it was not the final
# one (update(1) already emitted its own newline).
if (
self._bar is not None
and self._bar.last_progress is not None
and self._bar.last_progress != 100
):
self._bar.done()
def download_with_resume( def download_with_resume(
@@ -774,6 +811,7 @@ def download_with_resume(
attempts: int = 5, attempts: int = 5,
timeout: int = 30, timeout: int = 30,
retry_connect_errors: bool = True, retry_connect_errors: bool = True,
progress: Callable[[int], None] | None = None,
) -> None: ) -> None:
"""Download ``url`` to ``dest``, resuming partial downloads. """Download ``url`` to ``dest``, resuming partial downloads.
@@ -796,6 +834,12 @@ def download_with_resume(
of consuming attempts — for callers with their own fallback, like of consuming attempts — for callers with their own fallback, like
``download_from_mirrors``. ``download_from_mirrors``.
``progress``, when given, replaces the built-in progress bar: it is called
with the absolute number of bytes of ``dest`` obtained so far (including
a resumed prefix, and the final size once the file is verified), so a
caller running several downloads at once can draw one combined bar (see
``BatchDownloadProgress``).
Raises EsphomeError when all attempts are exhausted. Raises EsphomeError when all attempts are exhausted.
""" """
# Imported lazily: requests is a heavy import (~85ms) and is only needed # Imported lazily: requests is a heavy import (~85ms) and is only needed
@@ -819,6 +863,8 @@ def download_with_resume(
if dest.is_file() and (sha256 is not None or size is not None): if dest.is_file() and (sha256 is not None or size is not None):
try: try:
_verify_file(dest, sha256, size) _verify_file(dest, sha256, size)
if progress is not None:
progress(size if size is not None else dest.stat().st_size)
return return
except EsphomeError: except EsphomeError:
dest.unlink() dest.unlink()
@@ -864,7 +910,7 @@ def download_with_resume(
# Recorded so a later run can prove an If-Range # Recorded so a later run can prove an If-Range
# resume of this part file safe. # resume of this part file safe.
_write_download_meta(meta, url, validator, expected_total) _write_download_meta(meta, url, validator, expected_total)
_stream_response_to_file(resp, f, offset, size) _stream_response_to_file(resp, f, offset, size, progress)
# else: a previous run already wrote every byte (or more) but # else: a previous run already wrote every byte (or more) but
# was killed before the rename below. Skip the network entirely # was killed before the rename below. Skip the network entirely
# — a Range request past EOF would draw HTTP 416 — and let # — a Range request past EOF would draw HTTP 416 — and let
@@ -873,6 +919,10 @@ def download_with_resume(
expected_size = size if size is not None else expected_total expected_size = size if size is not None else expected_total
_verify_file(part, sha256, expected_size or None) _verify_file(part, sha256, expected_size or None)
if progress is not None:
# Also credits a part file an earlier run completed without
# streaming anything this time.
progress(expected_size or part.stat().st_size)
if not expected_size and sha256 is None: if not expected_size and sha256 is None:
# No sha, no size, and the server sent no usable # No sha, no size, and the server sent no usable
# content-length: nothing can prove the download complete # content-length: nothing can prove the download complete
@@ -975,6 +1025,7 @@ def _try_mirrors_once(
f: IO[bytes] | None, f: IO[bytes] | None,
timeout: int, timeout: int,
failures: list[tuple[str, Exception]], failures: list[tuple[str, Exception]],
progress: Callable[[int], None] | None = None,
) -> str | None: ) -> str | None:
"""Single pass over the resolved mirror ``urls``, one try per URL. """Single pass over the resolved mirror ``urls``, one try per URL.
@@ -1003,6 +1054,7 @@ def _try_mirrors_once(
# next mirror immediately; only mid-stream drops # next mirror immediately; only mid-stream drops
# retry-with-resume on the same URL. # retry-with-resume on the same URL.
retry_connect_errors=False, retry_connect_errors=False,
progress=progress,
) )
return url return url
except (requests.RequestException, OSError, EsphomeError) as e: except (requests.RequestException, OSError, EsphomeError) as e:
@@ -1044,7 +1096,7 @@ def _try_mirrors_once(
if offset == 0: if offset == 0:
validator = _response_validator(resp) validator = _response_validator(resp)
expected_total = _content_length(resp) expected_total = _content_length(resp)
_stream_response_to_file(resp, f, offset) _stream_response_to_file(resp, f, offset, progress=progress)
if expected_total and f.tell() != expected_total: if expected_total and f.tell() != expected_total:
raise EsphomeError( raise EsphomeError(
@@ -1093,6 +1145,7 @@ def download_from_mirrors(
substitutions: dict[str, str], substitutions: dict[str, str],
target: io.RawIOBase | IO[bytes] | PathType, target: io.RawIOBase | IO[bytes] | PathType,
timeout: int = 30, timeout: int = 30,
progress: Callable[[int], None] | None = None,
) -> str: ) -> str:
""" """
Download file from multiple mirrors with substitution support. Download file from multiple mirrors with substitution support.
@@ -1102,6 +1155,8 @@ def download_from_mirrors(
substitutions: Dictionary of substitutions to apply to URLs substitutions: Dictionary of substitutions to apply to URLs
target: Target file path or file-like object target: Target file path or file-like object
timeout: Download timeout in seconds timeout: Download timeout in seconds
progress: Passed through to the download (see ``download_with_resume``);
replaces the built-in per-file bar
Returns: Returns:
The source URL. The source URL.
@@ -1166,7 +1221,9 @@ def download_from_mirrors(
for sweep in range(1, _MIRROR_SWEEP_ATTEMPTS + 1): for sweep in range(1, _MIRROR_SWEEP_ATTEMPTS + 1):
sweep_failures: list[tuple[str, Exception]] = [] sweep_failures: list[tuple[str, Exception]] = []
if ( if (
url := _try_mirrors_once(urls, path_target, f, timeout, sweep_failures) url := _try_mirrors_once(
urls, path_target, f, timeout, sweep_failures, progress
)
) is not None: ) is not None:
return url return url
failures.extend(sweep_failures) failures.extend(sweep_failures)
+77 -14
View File
@@ -32,10 +32,10 @@ from urllib.request import url2pathname
from esphome import git from esphome import git
from esphome.core import CORE, EsphomeError, Library from esphome.core import CORE, EsphomeError, Library
from esphome.framework_helpers import ( from esphome.framework_helpers import (
BatchDownloadProgress,
archive_extract_all, archive_extract_all,
download_from_mirrors, download_from_mirrors,
rmdir, rmdir,
suppress_download_progress,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -86,7 +86,12 @@ ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE"
class Source: class Source:
def download( def download(
self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = "" self,
dir_suffix: str,
force: bool = False,
salt: str = "",
namespace: str = "",
progress: Callable[[int], None] | None = None,
) -> Path: ) -> Path:
raise NotImplementedError raise NotImplementedError
@@ -104,7 +109,12 @@ class URLSource(Source):
self.url = url self.url = url
def download( def download(
self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = "" self,
dir_suffix: str,
force: bool = False,
salt: str = "",
namespace: str = "",
progress: Callable[[int], None] | None = None,
) -> Path: ) -> Path:
# Namespace the cache per backend (e.g. pio_components/idf, .../zephyr) so # Namespace the cache per backend (e.g. pio_components/idf, .../zephyr) so
# the build files each backend writes into the library dir can't collide. # the build files each backend writes into the library dir can't collide.
@@ -127,10 +137,12 @@ class URLSource(Source):
# Download in temporary file # Download in temporary file
with tempfile.NamedTemporaryFile() as tmp: with tempfile.NamedTemporaryFile() as tmp:
_LOGGER.info("Downloading %s ...", self.url) if progress is None:
# A batch caller draws one combined bar and logs the list
_LOGGER.info("Downloading %s ...", self.url)
_LOGGER.debug("Location: %s", path) _LOGGER.debug("Location: %s", path)
download_from_mirrors([self.url], {}, tmp.file) download_from_mirrors([self.url], {}, tmp.file, progress=progress)
_LOGGER.debug("Extracting archive to %s ...", path) _LOGGER.debug("Extracting archive to %s ...", path)
archive_extract_all(tmp.file, path) archive_extract_all(tmp.file, path)
@@ -147,7 +159,12 @@ class GitSource(Source):
self.ref = ref self.ref = ref
def download( def download(
self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = "" self,
dir_suffix: str,
force: bool = False,
salt: str = "",
namespace: str = "",
progress: Callable[[int], None] | None = None,
) -> Path: ) -> Path:
domain = DOMAIN domain = DOMAIN
if namespace: if namespace:
@@ -182,7 +199,12 @@ class LocalSource(Source):
self.local_path = path self.local_path = path
def download( def download(
self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = "" self,
dir_suffix: str,
force: bool = False,
salt: str = "",
namespace: str = "",
progress: Callable[[int], None] | None = None,
) -> Path: ) -> Path:
src = Path(self.local_path) src = Path(self.local_path)
if not src.is_dir(): if not src.is_dir():
@@ -275,7 +297,13 @@ class ConvertedLibrary:
def get_require_name(self): def get_require_name(self):
return self.get_sanitized_name().replace("/", "__") return self.get_sanitized_name().replace("/", "__")
def download(self, force: bool = False, salt: str = "", namespace: str = ""): def download(
self,
force: bool = False,
salt: str = "",
namespace: str = "",
progress: Callable[[int], None] | None = None,
):
"""Fetch the library into the shared cache and record its ``path``. """Fetch the library into the shared cache and record its ``path``.
The cache directory is named after the sanitized library name; backends The cache directory is named after the sanitized library name; backends
@@ -284,7 +312,11 @@ class ConvertedLibrary:
``get_require_name``). ``namespace`` keeps each backend's cache separate. ``get_require_name``). ``namespace`` keeps each backend's cache separate.
""" """
self.path = self.source.download( self.path = self.source.download(
self.get_sanitized_name(), force=force, salt=salt, namespace=namespace self.get_sanitized_name(),
force=force,
salt=salt,
namespace=namespace,
progress=progress,
) )
self.source_path = self.source.source_root(self.path) self.source_path = self.source.source_root(self.path)
@@ -899,6 +931,21 @@ def _warn_unsatisfied_versionless(
_DOWNLOAD_WORKERS = 4 _DOWNLOAD_WORKERS = 4
def _content_lengths(urls: list[str]) -> list[int]:
"""Content-Length per URL via HEAD requests; 0 for any that fail."""
import requests
def head(url: str) -> int:
try:
resp = requests.head(url, timeout=10, allow_redirects=True)
return int(resp.headers.get("content-length", 0))
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
return 0
with ThreadPoolExecutor(max_workers=min(_DOWNLOAD_WORKERS, len(urls))) as ex:
return list(ex.map(head, urls))
def _prefetch_wave( def _prefetch_wave(
wave: list[tuple[str, ConvertedLibrary]], salt: str, namespace: str wave: list[tuple[str, ConvertedLibrary]], salt: str, namespace: str
) -> None: ) -> None:
@@ -920,17 +967,33 @@ def _prefetch_wave(
components.append(component) components.append(component)
if len(components) < 2: if len(components) < 2:
return return
_LOGGER.info(
"Downloading %d libraries: %s",
len(components),
", ".join(c.name for c in components),
)
# One combined bar over the batch; sizes come from HEAD requests so the
# bar can be trusted (no sizes -> no bar, per BatchDownloadProgress)
sizes = _content_lengths([c.source.url for c in components])
progress = BatchDownloadProgress(
"Downloading libraries", sum(sizes) if all(sizes) else 0
)
def _fetch(component: ConvertedLibrary) -> None: def _fetch(component: ConvertedLibrary) -> None:
tracker = progress.tracker()
try: try:
with suppress_download_progress(): component.download(salt=salt, namespace=namespace, progress=tracker)
component.download(salt=salt, namespace=namespace)
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
# The sequential call below retries and reports the failure # The sequential call below retries and reports the failure
pass tracker(0)
with ThreadPoolExecutor(max_workers=min(_DOWNLOAD_WORKERS, len(components))) as ex: try:
list(ex.map(_fetch, components)) with ThreadPoolExecutor(
max_workers=min(_DOWNLOAD_WORKERS, len(components))
) as ex:
list(ex.map(_fetch, components))
finally:
progress.done()
def convert_libraries( def convert_libraries(
@@ -176,3 +176,31 @@ def test_ar_batch_failure_stops(tmp_path: Path) -> None:
assert mock_run.call_count == 1 assert mock_run.call_count == 1
# The failed batch must not leave a truncated archive behind # The failed batch must not leave a truncated archive behind
assert not archive.exists() assert not archive.exists()
def test_surplus_arguments_error(capsys: pytest.CaptureFixture[str]) -> None:
"""A mis-specified ninja rule passing extra operands errors instead of
silently dropping them."""
with patch.object(
build_tool.sys, "argv", ["build_tool", "copy", "a", "b", "extra"]
):
assert build_tool.main() == 1
assert "expected 2 arguments, got 3" in capsys.readouterr().err
def test_copy_failure_leaves_no_partial_output(tmp_path: Path) -> None:
"""A failed copy unlinks the destination; a partial firmware image must
never be left on disk."""
dst = tmp_path / "firmware.factory.bin"
dst.write_text("stale")
with (
patch.object(build_tool.shutil, "copyfile", side_effect=OSError("disk full")),
patch.object(
build_tool.sys,
"argv",
["build_tool", "copy", str(tmp_path / "src.bin"), str(dst)],
),
pytest.raises(OSError),
):
build_tool.main()
assert not dst.exists()
+74 -13
View File
@@ -86,6 +86,7 @@ def _webserver(tmp_path: Path, data: dict) -> ConvertedLibrary:
_add_library("ESP32Async/ESPAsyncWebServer", "3.9.6") _add_library("ESP32Async/ESPAsyncWebServer", "3.9.6")
lib_dir = tmp_path / "converted" / "webserver" lib_dir = tmp_path / "converted" / "webserver"
(lib_dir / "src").mkdir(parents=True) (lib_dir / "src").mkdir(parents=True)
(lib_dir / "src" / "server.cpp").write_text("")
return _converted("esp32async__ESPAsyncWebServer", lib_dir, data) return _converted("esp32async__ESPAsyncWebServer", lib_dir, data)
@@ -108,8 +109,10 @@ def _ws_tcp_pair(tmp_path: Path) -> tuple[ConvertedLibrary, ConvertedLibrary]:
"""Build ESPAsyncWebServer (depending on ESPAsyncTCP) plus resolved TCP.""" """Build ESPAsyncWebServer (depending on ESPAsyncTCP) plus resolved TCP."""
ws_dir = tmp_path / "converted" / "webserver" ws_dir = tmp_path / "converted" / "webserver"
(ws_dir / "src").mkdir(parents=True) (ws_dir / "src").mkdir(parents=True)
(ws_dir / "src" / "server.cpp").write_text("")
tcp_dir = tmp_path / "converted" / "tcp" tcp_dir = tmp_path / "converted" / "tcp"
(tcp_dir / "src").mkdir(parents=True) (tcp_dir / "src").mkdir(parents=True)
(tcp_dir / "src" / "tcp.cpp").write_text("")
ws = _converted( ws = _converted(
"esp32async__ESPAsyncWebServer", "esp32async__ESPAsyncWebServer",
ws_dir, ws_dir,
@@ -182,22 +185,26 @@ def test_library_info_declared_filter_matches_nothing_warns(
) -> None: ) -> None:
read_path = tmp_path / "lib" read_path = tmp_path / "lib"
(read_path / "src").mkdir(parents=True) (read_path / "src").mkdir(parents=True)
(read_path / "src" / "stub.cpp").write_text("")
data = {"build": {"srcFilter": ["+<nothing/*>"]}} data = {"build": {"srcFilter": ["+<nothing/*>"]}}
lib = component._library_info("x", read_path, data) lib = component._library_info("x", read_path, data)
assert not lib.sources assert not lib.sources
assert "declares srcFilter/srcDir but no source files matched" in caplog.text assert "declares srcFilter/srcDir but no source files matched" in caplog.text
def test_library_info_empty_tree_warns( def test_empty_converted_tree_raises_at_emit(tmp_path: Path) -> None:
tmp_path: Path, caplog: pytest.LogCaptureFixture """A converted tree with no sources and no headers is a broken download;
) -> None: fail by name like the bundled case."""
"""No sources and no headers is an empty archive waiting to fail at framework = _make_framework(tmp_path)
link; warn by name even without a declared filter.""" _add_library("Some/Empty", "1.0.0")
read_path = tmp_path / "lib" lib_dir = tmp_path / "converted" / "empty"
(read_path / "src").mkdir(parents=True) (lib_dir / "src").mkdir(parents=True)
lib = component._library_info("x", read_path, {}) converted = _converted("some__Empty", lib_dir, {"build": {}})
assert not lib.sources with (
assert "has no sources or headers" in caplog.text _emitting_converter(converted),
pytest.raises(EsphomeError, match="no sources or headers; the download"),
):
_resolve(framework)
def test_library_info_no_src_dir(tmp_path: Path) -> None: def test_library_info_no_src_dir(tmp_path: Path) -> None:
@@ -275,6 +282,7 @@ def test_resolve_libraries_bundled_dep_already_present(tmp_path: Path) -> None:
lib_dir = tmp_path / "converted" / "external" lib_dir = tmp_path / "converted" / "external"
lib_dir.mkdir(parents=True) lib_dir.mkdir(parents=True)
(lib_dir / "main.cpp").write_text("")
converted = _converted( converted = _converted(
"some__External", lib_dir, {"dependencies": [{"name": "Wire"}]} "some__External", lib_dir, {"dependencies": [{"name": "Wire"}]}
) )
@@ -291,6 +299,7 @@ def test_library_info_trailing_bare_flag_warns(
) -> None: ) -> None:
read_path = tmp_path / "lib" read_path = tmp_path / "lib"
(read_path / "src").mkdir(parents=True) (read_path / "src").mkdir(parents=True)
(read_path / "src" / "stub.cpp").write_text("")
lib = component._library_info("x", read_path, {"build": {"flags": ["-DA=1 -l"]}}) lib = component._library_info("x", read_path, {"build": {"flags": ["-DA=1 -l"]}})
assert lib.flags == ["-DA=1"] assert lib.flags == ["-DA=1"]
assert lib.link_libs == [] assert lib.link_libs == []
@@ -302,6 +311,7 @@ def test_library_info_missing_explicit_include_warns(
) -> None: ) -> None:
read_path = tmp_path / "lib" read_path = tmp_path / "lib"
(read_path / "src").mkdir(parents=True) (read_path / "src").mkdir(parents=True)
(read_path / "src" / "stub.cpp").write_text("")
lib = component._library_info("x", read_path, {"build": {"flags": ["-Inope"]}}) lib = component._library_info("x", read_path, {"build": {"flags": ["-Inope"]}})
assert lib.include_dirs == [(read_path / "src").resolve()] assert lib.include_dirs == [(read_path / "src").resolve()]
assert "include dir nope which does not exist" in caplog.text assert "include dir nope which does not exist" in caplog.text
@@ -343,6 +353,7 @@ def test_resolve_libraries_lib_ignore_covers_bundled_dependencies(
lib_dir = tmp_path / "converted" / "external" lib_dir = tmp_path / "converted" / "external"
lib_dir.mkdir(parents=True) lib_dir.mkdir(parents=True)
(lib_dir / "main.cpp").write_text("")
converted = _converted( converted = _converted(
"some__External", lib_dir, {"dependencies": [{"name": "Wire"}]} "some__External", lib_dir, {"dependencies": [{"name": "Wire"}]}
) )
@@ -373,6 +384,7 @@ def test_library_info_lib_archive_flag(tmp_path: Path) -> None:
the generator's contract; default is archive.""" the generator's contract; default is archive."""
read_path = tmp_path / "lib" read_path = tmp_path / "lib"
(read_path / "src").mkdir(parents=True) (read_path / "src").mkdir(parents=True)
(read_path / "src" / "stub.cpp").write_text("")
assert component._library_info("x", read_path, {}).lib_archive is True assert component._library_info("x", read_path, {}).lib_archive is True
assert ( assert (
component._library_info( component._library_info(
@@ -460,6 +472,47 @@ def test_nonplatform_rejection_warns_once_through_real_converter(
assert caplog.text.count("manifest is corrupt") == 1 assert caplog.text.count("manifest is corrupt") == 1
def test_url_pinned_bundled_name_not_doubled(tmp_path: Path) -> None:
"""A URL-pinned dependency names one specific source; the bundled copy
of the same short name must never be added on top of the fork."""
framework = _make_framework(tmp_path)
converted = _webserver(
tmp_path,
{
"build": {},
"dependencies": [
{"name": "Wire", "version": "https://github.com/x/wire-fork.git"}
],
},
)
with _emitting_converter(converted):
libs = _resolve(framework)
assert "Wire" not in [lib.name for lib in libs]
def test_versioned_bundled_candidate_fault_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A versioned bundled-name dependency skips the walk's usability filter
via provides(), so a non-platform fault warns here."""
framework = _make_framework(tmp_path)
converted = _webserver(
tmp_path,
{"build": {}, "dependencies": [{"name": "Wire", "version": "*"}]},
)
with (
_emitting_converter(converted),
patch.object(
component,
"check_library_data",
side_effect=InvalidLibrary("manifest is corrupt"),
),
):
libs = _resolve(framework)
assert "Wire" not in [lib.name for lib in libs]
assert "Skipping bundled dependency Wire" in caplog.text
def test_short_name_collision_with_bundled_name_warns( def test_short_name_collision_with_bundled_name_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
@@ -473,6 +526,7 @@ def test_short_name_collision_with_bundled_name_warns(
{"build": {}, "dependencies": [{"name": "Wire"}]}, {"build": {}, "dependencies": [{"name": "Wire"}]},
) )
(tmp_path / "conv" / "src").mkdir(parents=True) (tmp_path / "conv" / "src").mkdir(parents=True)
(tmp_path / "conv" / "src" / "a.cpp").write_text("")
with _emitting_converter(converted): with _emitting_converter(converted):
libs = _resolve(framework) libs = _resolve(framework)
assert "Wire" not in [lib.name for lib in libs] assert "Wire" not in [lib.name for lib in libs]
@@ -508,6 +562,7 @@ def test_library_info_falsy_declared_src_dir_raises(
"""A declared-but-falsy srcDir must not silently fall back to the probe.""" """A declared-but-falsy srcDir must not silently fall back to the probe."""
read_path = tmp_path / "lib" read_path = tmp_path / "lib"
(read_path / "src").mkdir(parents=True) (read_path / "src").mkdir(parents=True)
(read_path / "src" / "stub.cpp").write_text("")
with pytest.raises(EsphomeError, match="does not exist"): with pytest.raises(EsphomeError, match="does not exist"):
component._library_info("x", read_path, {"build": {"srcDir": declared}}) component._library_info("x", read_path, {"build": {"srcDir": declared}})
@@ -529,6 +584,7 @@ def test_library_info_lib_archive_parse(
"""bool("false") is True; the string forms must parse, not coerce.""" """bool("false") is True; the string forms must parse, not coerce."""
read_path = tmp_path / "lib" read_path = tmp_path / "lib"
(read_path / "src").mkdir(parents=True) (read_path / "src").mkdir(parents=True)
(read_path / "src" / "stub.cpp").write_text("")
lib = component._library_info("x", read_path, {"build": {"libArchive": value}}) lib = component._library_info("x", read_path, {"build": {"libArchive": value}})
assert lib.lib_archive is expected assert lib.lib_archive is expected
@@ -539,6 +595,7 @@ def test_library_info_dropped_link_fields_warn(
"""precompiled/ldflags properties are not honored; the drop is named.""" """precompiled/ldflags properties are not honored; the drop is named."""
read_path = tmp_path / "lib" read_path = tmp_path / "lib"
(read_path / "src").mkdir(parents=True) (read_path / "src").mkdir(parents=True)
(read_path / "src" / "stub.cpp").write_text("")
component._library_info( component._library_info(
"x", read_path, {"precompiled": "true", "ldflags": "-lfoo", "build": {}} "x", read_path, {"precompiled": "true", "ldflags": "-lfoo", "build": {}}
) )
@@ -604,6 +661,7 @@ def test_library_info_lib_archive_malformed_raises(tmp_path: Path) -> None:
"""A typo'd libArchive fails by name like the other build fields.""" """A typo'd libArchive fails by name like the other build fields."""
read_path = tmp_path / "lib" read_path = tmp_path / "lib"
(read_path / "src").mkdir(parents=True) (read_path / "src").mkdir(parents=True)
(read_path / "src" / "stub.cpp").write_text("")
with pytest.raises(EsphomeError, match="malformed libArchive value 'archive-me'"): with pytest.raises(EsphomeError, match="malformed libArchive value 'archive-me'"):
component._library_info("x", read_path, {"build": {"libArchive": "archive-me"}}) component._library_info("x", read_path, {"build": {"libArchive": "archive-me"}})
@@ -674,6 +732,7 @@ def test_library_info_malformed_build_fields_are_named(
"""Malformed includeDir/srcFilter fail naming the library like srcDir.""" """Malformed includeDir/srcFilter fail naming the library like srcDir."""
read_path = tmp_path / "lib" read_path = tmp_path / "lib"
(read_path / "src").mkdir(parents=True) (read_path / "src").mkdir(parents=True)
(read_path / "src" / "stub.cpp").write_text("")
with pytest.raises(EsphomeError, match=match): with pytest.raises(EsphomeError, match=match):
component._library_info("x", read_path, {"build": build}) component._library_info("x", read_path, {"build": build})
@@ -693,6 +752,7 @@ def test_library_info_dot_a_linkage_parses_strictly(
"""The dot_a_linkage property uses the same strict table as libArchive.""" """The dot_a_linkage property uses the same strict table as libArchive."""
read_path = tmp_path / "lib" read_path = tmp_path / "lib"
(read_path / "src").mkdir(parents=True) (read_path / "src").mkdir(parents=True)
(read_path / "src" / "stub.cpp").write_text("")
lib = component._library_info("x", read_path, {"dot_a_linkage": value, "build": {}}) lib = component._library_info("x", read_path, {"dot_a_linkage": value, "build": {}})
assert lib.lib_archive is expected assert lib.lib_archive is expected
@@ -701,6 +761,7 @@ def test_library_info_dot_a_linkage_malformed_raises(tmp_path: Path) -> None:
"""A typo'd dot_a_linkage must not silently flip link semantics.""" """A typo'd dot_a_linkage must not silently flip link semantics."""
read_path = tmp_path / "lib" read_path = tmp_path / "lib"
(read_path / "src").mkdir(parents=True) (read_path / "src").mkdir(parents=True)
(read_path / "src" / "stub.cpp").write_text("")
with pytest.raises(EsphomeError, match="malformed dot_a_linkage value 'yes'"): with pytest.raises(EsphomeError, match="malformed dot_a_linkage value 'yes'"):
component._library_info("x", read_path, {"dot_a_linkage": "yes", "build": {}}) component._library_info("x", read_path, {"dot_a_linkage": "yes", "build": {}})
@@ -901,8 +962,10 @@ def test_converted_manifest_name_suppresses_bundled_dependency(
_add_library("Someone/WireLib", "9.9.9") _add_library("Someone/WireLib", "9.9.9")
ws_dir = tmp_path / "converted" / "webserver" ws_dir = tmp_path / "converted" / "webserver"
(ws_dir / "src").mkdir(parents=True) (ws_dir / "src").mkdir(parents=True)
(ws_dir / "src" / "stub.cpp").write_text("")
wire_dir = tmp_path / "converted" / "wire" wire_dir = tmp_path / "converted" / "wire"
(wire_dir / "src").mkdir(parents=True) (wire_dir / "src").mkdir(parents=True)
(wire_dir / "src" / "wire.cpp").write_text("")
ws = _converted( ws = _converted(
"esp32async__ESPAsyncWebServer", "esp32async__ESPAsyncWebServer",
ws_dir, ws_dir,
@@ -939,9 +1002,7 @@ def test_empty_bundled_library_warns(
framework = _make_framework(tmp_path) framework = _make_framework(tmp_path)
(framework / "libraries" / "Empty").mkdir() (framework / "libraries" / "Empty").mkdir()
_add_library("Empty", None) _add_library("Empty", None)
with pytest.raises( with pytest.raises(EsphomeError, match="Library Empty has no sources or headers"):
EsphomeError, match="Bundled library Empty has no sources or headers"
):
_resolve(framework) _resolve(framework)
+1 -1
View File
@@ -1066,7 +1066,7 @@ def test_idf_component_download_passes_salt() -> None:
c.download(force=True, salt="abcd1234", namespace="idf") c.download(force=True, salt="abcd1234", namespace="idf")
source.download.assert_called_once_with( source.download.assert_called_once_with(
"owner/name", force=True, salt="abcd1234", namespace="idf" "owner/name", force=True, salt="abcd1234", namespace="idf", progress=None
) )
assert c.path == Path("/converted/owner/name") assert c.path == Path("/converted/owner/name")
+93 -8
View File
@@ -2,6 +2,7 @@
# pylint: disable=protected-access # pylint: disable=protected-access
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager from contextlib import contextmanager
import importlib.util import importlib.util
import io import io
@@ -14,7 +15,7 @@ import subprocess
import sys import sys
import tarfile import tarfile
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -895,16 +896,72 @@ def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None:
), ),
patch("esphome.espidf.framework.download_with_resume") as download, patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"), patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.espidf.framework.BatchDownloadProgress") as progress_cls,
): ):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None) _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
dist = get_idf_tools_path() / "dist" dist = get_idf_tools_path() / "dist"
assert download.call_count == 2 # Archives download concurrently, so the call order is not fixed.
assert download.call_args_list[0][0] == ( calls = {call[0]: call[1] for call in download.call_args_list}
"https://example.com/cmake.tar.gz", assert set(calls) == {
dist / "cmake-3.30.2.tar.gz", ("https://example.com/cmake.tar.gz", dist / "cmake-3.30.2.tar.gz"),
) ("https://example.com/ninja.zip", dist / "ninja.zip"),
assert download.call_args_list[0][1] == {"sha256": "ab" * 32, "size": 123} }
kwargs = calls[("https://example.com/cmake.tar.gz", dist / "cmake-3.30.2.tar.gz")]
assert kwargs["sha256"] == "ab" * 32
assert kwargs["size"] == 123
# every archive reports into the one combined progress bar
progress_cls.assert_called_once_with("Downloading ESP-IDF tools", 123 + 45)
tracker = progress_cls.return_value.tracker.return_value
assert all(kw["progress"] is tracker for kw in calls.values())
def test_prefetch_downloads_archives_concurrently(tmp_path: Path) -> None:
"""More than one archive fans out over a bounded thread pool."""
entries = [
{
"name": f"tool{i}@1",
"url": f"https://example.com/tool{i}.tar.gz",
"size": 10,
"sha256": "ab" * 32,
"dest": f"tool{i}.tar.gz",
}
for i in range(6)
]
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch(
"esphome.espidf.framework.ThreadPoolExecutor", wraps=ThreadPoolExecutor
) as pool,
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
pool.assert_called_once_with(max_workers=4)
assert download.call_count == 6
def test_prefetch_single_archive_uses_one_worker(tmp_path: Path) -> None:
entries = json.loads(_PREFETCH_JSON)[:1]
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch(
"esphome.espidf.framework.ThreadPoolExecutor", wraps=ThreadPoolExecutor
) as pool,
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
pool.assert_called_once_with(max_workers=1)
assert download.call_count == 1
def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None: def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None:
@@ -964,6 +1021,11 @@ def test_prefetch_one_failed_archive_does_not_stop_the_rest(
) -> None: ) -> None:
"""A single archive failing its download must not abort the prefetch of """A single archive failing its download must not abort the prefetch of
the remaining archives.""" the remaining archives."""
def _fail_cmake_download(url: str, *args, **kwargs) -> None:
if "cmake" in url:
raise OSError("network down")
with ( with (
patch( patch(
"esphome.espidf.framework.run_command", "esphome.espidf.framework.run_command",
@@ -971,7 +1033,7 @@ def test_prefetch_one_failed_archive_does_not_stop_the_rest(
), ),
patch( patch(
"esphome.espidf.framework.download_with_resume", "esphome.espidf.framework.download_with_resume",
side_effect=[OSError("network down"), None], side_effect=_fail_cmake_download,
) as download, ) as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"), patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
): ):
@@ -981,6 +1043,29 @@ def test_prefetch_one_failed_archive_does_not_stop_the_rest(
assert "Could not prefetch cmake@3.30.2" in caplog.text assert "Could not prefetch cmake@3.30.2" in caplog.text
def test_prefetch_finishes_progress_bar_and_cancels_queue(tmp_path: Path) -> None:
"""The batch bar is closed out after the pool, and the pool is shut down
with cancel_futures so Ctrl-C does not drain every queued archive."""
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch("esphome.espidf.framework.download_with_resume"),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.espidf.framework.BatchDownloadProgress") as progress_cls,
patch(
"esphome.espidf.framework.ThreadPoolExecutor", wraps=ThreadPoolExecutor
) as pool_cls,
):
pool = MagicMock(wraps=ThreadPoolExecutor(max_workers=2))
pool_cls.return_value = pool
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
pool.shutdown.assert_called_once_with(wait=True, cancel_futures=True)
progress_cls.return_value.done.assert_called_once_with()
def test_prefetch_passes_targets_and_tools_to_script(tmp_path: Path) -> None: def test_prefetch_passes_targets_and_tools_to_script(tmp_path: Path) -> None:
with ( with (
patch( patch(
+103 -18
View File
@@ -21,6 +21,7 @@ import requests as req
from esphome import framework_helpers from esphome import framework_helpers
from esphome.core import EsphomeError from esphome.core import EsphomeError
from esphome.framework_helpers import ( from esphome.framework_helpers import (
BatchDownloadProgress,
_7z_extract_all, _7z_extract_all,
_detect_archive_root, _detect_archive_root,
_is_transient_download_error, _is_transient_download_error,
@@ -1112,6 +1113,108 @@ class TestDownloadWithResume:
assert mock_get.call_args[1]["headers"] == {} assert mock_get.call_args[1]["headers"] == {}
assert dest.read_bytes() == b"data" assert dest.read_bytes() == b"data"
def test_progress_callback_reports_absolute_bytes(self, tmp_path: Path) -> None:
"""With a callback no bar is drawn; the callback sees the running
byte count of this file, then its final verified size."""
dest = tmp_path / "tool.tar.gz"
resp = _mock_response(b"")
resp.headers = {"content-length": "7"}
resp.iter_content.return_value = [b"1234", b"567"]
seen: list[int] = []
with (
patch("requests.get", return_value=resp),
patch("esphome.framework_helpers.ProgressBar") as bar,
):
download_with_resume(
"https://example.com/t", dest, size=7, progress=seen.append
)
assert seen == [0, 4, 7, 7]
bar.assert_not_called()
def test_progress_callback_seeds_with_resume_offset(self, tmp_path: Path) -> None:
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"12345")
good = hashlib.sha256(b"12345678").hexdigest()
seen: list[int] = []
with patch("requests.get", return_value=_resumed_response(b"678")):
download_with_resume(
"https://example.com/t", dest, sha256=good, size=8, progress=seen.append
)
assert seen[0] == 5
assert seen[-1] == 8
def test_progress_callback_credits_already_complete_download(
self, tmp_path: Path
) -> None:
"""A verified dest from an earlier run still counts toward the batch."""
dest = tmp_path / "tool.tar.gz"
dest.write_bytes(b"12345678")
seen: list[int] = []
with patch("requests.get") as mock_get:
download_with_resume(
"https://example.com/t", dest, size=8, progress=seen.append
)
mock_get.assert_not_called()
assert seen == [8]
class TestBatchDownloadProgress:
def test_sums_trackers_into_one_bar(self) -> None:
with patch("esphome.framework_helpers.ProgressBar") as bar_cls:
progress = BatchDownloadProgress("Downloading", 100)
a = progress.tracker()
b = progress.tracker()
a(10)
b(20)
a(30)
a(0) # a restart from zero takes that file's bytes back out
bar_cls.assert_called_once_with("Downloading")
updates = [c[0][0] for c in bar_cls.return_value.update.call_args_list]
assert updates == [0.1, 0.3, 0.5, 0.2]
def test_clamps_at_one(self) -> None:
"""Sizes are advisory; an over-delivering server never pushes past 100%."""
with patch("esphome.framework_helpers.ProgressBar") as bar_cls:
progress = BatchDownloadProgress("Downloading", 10)
progress.tracker()(25)
assert bar_cls.return_value.update.call_args[0][0] == 1
def test_unknown_total_draws_nothing(self) -> None:
with patch("esphome.framework_helpers.ProgressBar") as bar_cls:
progress = BatchDownloadProgress("Downloading", 0)
progress.tracker()(5)
progress.done()
bar_cls.assert_not_called()
def test_done_ends_an_unfinished_bar(self) -> None:
"""A batch that stops short of 100% (a failed archive) still ends its
line so the next log message starts on a fresh row."""
stream = io.StringIO()
stream.isatty = lambda: True # type: ignore[method-assign]
with patch("esphome.helpers.sys.stderr", stream):
progress = BatchDownloadProgress("Downloading", 10)
progress.tracker()(5)
progress.done()
assert stream.getvalue().endswith("50% \n")
def test_done_before_any_frame_writes_nothing(self) -> None:
"""A batch aborted before any tracker fired must not emit a stray
newline for a bar that was never drawn."""
stream = io.StringIO()
stream.isatty = lambda: True # type: ignore[method-assign]
with patch("esphome.helpers.sys.stderr", stream):
BatchDownloadProgress("Downloading", 10).done()
assert stream.getvalue() == ""
def test_done_after_full_bar_adds_nothing(self) -> None:
stream = io.StringIO()
stream.isatty = lambda: True # type: ignore[method-assign]
with patch("esphome.helpers.sys.stderr", stream):
progress = BatchDownloadProgress("Downloading", 10)
progress.tracker()(10)
progress.done()
assert stream.getvalue().endswith("100% Done...\r\n")
class TestDownloadFromMirrors: class TestDownloadFromMirrors:
def test_success_returns_url_and_writes_content(self, tmp_path: Path) -> None: def test_success_returns_url_and_writes_content(self, tmp_path: Path) -> None:
@@ -2125,21 +2228,3 @@ def test_strip_win_long_path_prefix(
r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32.""" r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32."""
with patch("esphome.framework_helpers.sys.platform", platform): with patch("esphome.framework_helpers.sys.platform", platform):
assert framework_helpers.strip_win_long_path_prefix(input_path) == expected assert framework_helpers.strip_win_long_path_prefix(input_path) == expected
def test_suppress_download_progress_is_thread_local() -> None:
"""The bar suppression only affects the thread that entered the context."""
import threading
from esphome import framework_helpers as fh
seen: list[bool] = []
with fh.suppress_download_progress():
assert getattr(fh._PROGRESS_LOCAL, "disabled", False) is True
thread = threading.Thread(
target=lambda: seen.append(getattr(fh._PROGRESS_LOCAL, "disabled", False))
)
thread.start()
thread.join()
assert seen == [False]
assert getattr(fh._PROGRESS_LOCAL, "disabled", False) is False
+49 -4
View File
@@ -4,9 +4,11 @@ Covers the shared download/parse/resolve/dependency-walk paths in
``esphome.platformio.library`` directly (the ESP-IDF and Zephyr backends are ``esphome.platformio.library`` directly (the ESP-IDF and Zephyr backends are
exercised in their own test modules).""" exercised in their own test modules)."""
from contextlib import contextmanager
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
import pytest import pytest
@@ -157,11 +159,26 @@ def test_localsource_download_returns_empty_build_dir(setup_core: Path) -> None:
assert plain != out assert plain != out
@contextmanager
def caplog_at_info():
records: list[logging.LogRecord] = []
handler = logging.Handler()
handler.emit = records.append
logger = logging.getLogger("esphome.platformio.library")
logger.addHandler(handler)
try:
yield records
finally:
logger.removeHandler(handler)
def test_urlsource_download_extracts_then_reuses_marker(setup_core, monkeypatch): def test_urlsource_download_extracts_then_reuses_marker(setup_core, monkeypatch):
monkeypatch.setattr(lib, "rmdir", lambda path, msg="": None) monkeypatch.setattr(lib, "rmdir", lambda path, msg="": None)
dl_calls: list[list[str]] = [] dl_calls: list[list[str]] = []
monkeypatch.setattr( monkeypatch.setattr(
lib, "download_from_mirrors", lambda urls, headers, f: dl_calls.append(urls) lib,
"download_from_mirrors",
lambda urls, headers, f, progress=None: dl_calls.append(urls),
) )
def fake_extract(fileobj, path): def fake_extract(fileobj, path):
@@ -180,6 +197,12 @@ def test_urlsource_download_extracts_then_reuses_marker(setup_core, monkeypatch)
assert out2 == out assert out2 == out
assert len(dl_calls) == 1 assert len(dl_calls) == 1
# A batch caller passes a tracker and owns the messaging; no per-file INFO
with caplog_at_info() as records:
src.download("mylib-batch", progress=lambda done: None)
assert len(dl_calls) == 2
assert not [r for r in records if "Downloading" in r.message]
def test_resolve_registry_version_raises_without_pkg_file(monkeypatch): def test_resolve_registry_version_raises_without_pkg_file(monkeypatch):
registry = lib._make_registry_client() registry = lib._make_registry_client()
@@ -220,7 +243,7 @@ def _patch_registry_resolve(monkeypatch: pytest.MonkeyPatch) -> None:
def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()): def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()):
"""Fake ConvertedLibrary.download to materialize canned manifests on disk.""" """Fake ConvertedLibrary.download to materialize canned manifests on disk."""
def fake_download(self, force=False, salt="", namespace=""): def fake_download(self, force=False, salt="", namespace="", progress=None):
self.path = tmp_path / self.get_require_name() self.path = tmp_path / self.get_require_name()
self.path.mkdir(parents=True, exist_ok=True) self.path.mkdir(parents=True, exist_ok=True)
if self.name in properties: if self.name in properties:
@@ -299,7 +322,11 @@ def _patch_download_without_manifest(
calls: list[bool] = [] calls: list[bool] = []
def fake_download( def fake_download(
self: ConvertedLibrary, force: bool = False, salt: str = "", namespace: str = "" self: ConvertedLibrary,
force: bool = False,
salt: str = "",
namespace: str = "",
progress=None,
) -> None: ) -> None:
calls.append(force) calls.append(force)
self.path = tmp_path / self.get_require_name() self.path = tmp_path / self.get_require_name()
@@ -610,8 +637,10 @@ def test_prefetch_wave_downloads_registry_archives_in_parallel(
git/local sources and failures are left to the sequential call.""" git/local sources and failures are left to the sequential call."""
calls: list[str] = [] calls: list[str] = []
def fake_download(self, force=False, salt="", namespace=""): def fake_download(self, force=False, salt="", namespace="", progress=None):
calls.append(self.source.url) calls.append(self.source.url)
if progress is not None:
progress(0)
if "boom" in self.source.url: if "boom" in self.source.url:
raise RuntimeError("boom") raise RuntimeError("boom")
@@ -633,6 +662,22 @@ def test_prefetch_wave_downloads_registry_archives_in_parallel(
] ]
def test_content_lengths_head_requests(monkeypatch: pytest.MonkeyPatch) -> None:
"""Sizes come from HEAD Content-Length; a failing HEAD reads as 0 so
the combined bar is skipped rather than wrong."""
import requests
def fake_head(url, timeout, allow_redirects):
if "bad" in url:
raise requests.ConnectionError("down")
return SimpleNamespace(headers={"content-length": "123"})
monkeypatch.setattr(
lib.requests if hasattr(lib, "requests") else requests, "head", fake_head
)
assert lib._content_lengths(["https://x/a", "https://x/bad"]) == [123, 0]
def test_prefetch_wave_single_archive_skips_the_pool( def test_prefetch_wave_single_archive_skips_the_pool(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None: