[core] Prefetch PlatformIO packages in parallel (#18769)

This commit is contained in:
J. Nick Koston
2026-08-27 14:42:30 +12:00
committed by GitHub
parent e53870085b
commit 2104096f02
9 changed files with 1713 additions and 49 deletions
+9 -19
View File
@@ -2,7 +2,6 @@
from collections.abc import Callable
from ctypes.util import find_library
from functools import partial
import json
import logging
import os
@@ -24,16 +23,17 @@ from esphome.framework_helpers import (
create_venv,
download_and_extract,
download_from_mirrors,
download_with_resume,
failure_reason,
get_python_env_executable_path,
get_system_python_path,
resume_fetch_job,
rmdir,
run_batch_downloads,
run_command,
run_command_ok,
str_to_lst_of_str,
tool_version_runs,
warn_prefetch_failures,
)
from esphome.helpers import write_file_if_changed
@@ -686,18 +686,6 @@ def _patch_tools_json_demote_unused_tools(framework_path: Path) -> None:
)
def _download_tool(
dist_path: Path, entry: dict, tracker: Callable[[int], None]
) -> None:
download_with_resume(
entry["url"],
dist_path / entry["dest"],
sha256=entry["sha256"],
size=entry["size"],
progress=tracker,
)
def _prefetch_idf_tool_archives(
framework_path: Path,
targets_str: str,
@@ -775,15 +763,17 @@ def _prefetch_idf_tool_archives(
(
entry["name"],
entry["size"],
partial(_download_tool, dist_path, entry),
resume_fetch_job(
entry["url"],
dist_path / entry["dest"],
sha256=entry["sha256"],
size=entry["size"],
),
)
for entry in entries
],
)
for name, e in failures:
# failure_reason: a message-less exception must not log blank
_LOGGER.warning("Could not prefetch %s: %s", name, failure_reason(e))
_LOGGER.debug("Prefetch failure detail", exc_info=e)
warn_prefetch_failures(failures)
if len(failures) == len(entries):
# A systematic fault, not one flaky mirror: the resume
# workaround (#17703) is off for this whole install
+41 -3
View File
@@ -701,7 +701,7 @@ def _write_download_meta(
_LOGGER.debug("Could not update download metadata %s: %s", meta, e)
def _content_length(resp: "requests.Response") -> int:
def content_length(resp: "requests.Response") -> int:
"""Return the response's Content-Length, or 0 when absent or malformed.
0 means "unknown", which downstream disables the progress bar and the
@@ -744,7 +744,7 @@ def _stream_response_to_file(
"""
f.seek(offset)
f.truncate(offset)
total_size = size or offset + _content_length(resp)
total_size = size or offset + content_length(resp)
downloaded = offset
own_bar: ProgressBar | None = None
if progress is None:
@@ -909,6 +909,19 @@ def _part_path(dest: Path) -> Path:
return dest.with_name(dest.name + ".part")
def discard_partial_download(dest: Path) -> None:
"""Remove ``dest`` and the resume sidecars of an abandoned download."""
part = _part_path(dest)
for stale in (dest, part, part.with_name(part.name + ".meta")):
try:
stale.unlink()
except FileNotFoundError:
continue
except OSError as err:
# The caller's cache is never pruned; leave a trace
_LOGGER.debug("Could not remove %s: %s", stale, err)
def _cancellable_sleep(
delay: float, progress: Callable[[int], None] | None, done: int
) -> None:
@@ -922,6 +935,31 @@ def _cancellable_sleep(
time.sleep(min(0.5, remaining))
def resume_fetch_job(
url: str, dest: PathType, **kwargs
) -> Callable[[Callable[[int], None]], None]:
"""A ``run_batch_downloads`` job callable wrapping ``download_with_resume``.
Forwards the runner's positional tracker as the ``progress`` keyword.
"""
def fetch(tracker: Callable[[int], None]) -> None:
download_with_resume(url, dest, progress=tracker, **kwargs)
return fetch
def warn_prefetch_failures(
failures: list[tuple[str, BaseException]],
message: str = "Could not prefetch %s: %s",
) -> None:
"""Warn per failed batch-prefetch job; the caller's installer retries them."""
for name, err in failures:
# failure_reason: a message-less exception must not log blank
_LOGGER.warning(message, name, failure_reason(err))
_LOGGER.debug("Prefetch failure detail", exc_info=err)
def download_with_resume(
url: str,
dest: PathType,
@@ -1022,7 +1060,7 @@ def download_with_resume(
streamed = True
if offset == 0:
validator = _response_validator(resp)
expected_total = _content_length(resp)
expected_total = content_length(resp)
# Recorded so a later run can prove an If-Range
# resume of this part file safe.
_write_download_meta(meta, url, validator, expected_total)
+5 -8
View File
@@ -35,6 +35,7 @@ from esphome.framework_helpers import (
failure_reason,
rmdir,
run_batch_downloads,
warn_prefetch_failures,
)
_LOGGER = logging.getLogger(__name__)
@@ -977,14 +978,10 @@ def _prefetch_wave(
for c in components
],
)
for name, err in failures:
# The sequential call below retries and raises the real error
_LOGGER.warning(
"Prefetch of %s failed (retrying sequentially): %s",
name,
failure_reason(err),
)
_LOGGER.debug("Prefetch failure detail", exc_info=err)
# The sequential call below retries and raises the real error
warn_prefetch_failures(
failures, "Prefetch of %s failed (retrying sequentially): %s"
)
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
# Same policy as the ESP-IDF twin: the prefetch must never become a
# new way for the build to fail
+600
View File
@@ -0,0 +1,600 @@
"""Parallel prefetch of the packages a PlatformIO run would install.
Downloads the archives concurrently into PlatformIO's own download cache
(identical ``compute_download_path`` keys) so the serial installer finds
them already cached. Runs in a subprocess like all PlatformIO execution:
loading a platform executes its code (pioarduino's penv setup rewrites
``sys.path``). A sentinel in the build dir lets warm builds skip the
spawn. Best-effort: any failure logs and PlatformIO downloads as before.
Across processes sharing a core dir every download destination is
serialized by a file lock; checksum-less URL downloads additionally
stage under a stable name and promote with an atomic rename.
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
import hashlib
import json
import logging
import os
from pathlib import Path
import subprocess
import sys
import threading
import time
from typing import Any
from esphome.framework_helpers import (
content_length,
discard_partial_download,
failure_reason,
resume_fetch_job,
run_batch_downloads,
warn_prefetch_failures,
)
from esphome.helpers import get_bool_env
_LOGGER = logging.getLogger(__name__)
# Concurrent registry resolutions / HEAD probes (each is network-bound)
_RESOLVE_WORKERS = 8
# A hung child must not block the build; downloads resume on the next run
_PREFETCH_TIMEOUT = 20 * 60
# Waiting on another process's URL download; past this, leave it to pio
_DOWNLOAD_LOCK_TIMEOUT = 60
# Child exit for a handled, already-warned failure; 1 would collide with
# the interpreter's own import-failure exit
_EXIT_HANDLED = 3
# Short lock-acquire slices so a waiting worker still observes Ctrl-C
_URI_LOCK_POLL = 1
# Resolution errored (vs a clean skip); suppresses the warm sentinel
_RESOLVE_FAILED = object()
def _sweep_stale_sidecars(download_dir: Path, expire_seconds: int) -> None:
"""Prune resume sidecars pio's usage.db pruner cannot see.
A version bump strands an aborted archive's sidecars forever. Lock
files stay: a held lock can carry an ancient mtime (O_TRUNC keeps
it), and unlinking one reopens the single-writer hole it guards.
"""
cutoff = time.time() - expire_seconds
try:
for f in download_dir.iterdir():
if f.suffix not in (".part", ".meta", ".prefetch"):
continue
try:
if f.stat().st_mtime < cutoff:
f.unlink()
except OSError as err:
_LOGGER.debug("Could not remove %s: %s", f, err)
except OSError:
_LOGGER.debug("Could not sweep %s", download_dir, exc_info=True)
# Child records a no-work run; the parent skips the next spawn while valid
_SENTINEL_NAME = ".esphome_prefetch.json"
_SENTINEL_SCHEMA = 1
def _ini_sha256(build_dir: Path) -> str:
return hashlib.sha256((build_dir / "platformio.ini").read_bytes()).hexdigest()
def _sentinel_state(build_dir: Path) -> dict[str, Any]:
"""The environment fingerprint a sentinel must match to stay valid."""
# Same fingerprint as the heal stamp: the sentinel's dirs die with its wipe
from esphome.platformio.toolchain import current_python_minor
return {
"schema": _SENTINEL_SCHEMA,
"ini_sha256": _ini_sha256(build_dir),
"python": current_python_minor(),
"core_dir_env": os.environ.get("PLATFORMIO_CORE_DIR", ""),
}
def _prefetch_is_warm(build_dir: Path) -> bool:
"""Whether the last prefetch found nothing to do and nothing changed since."""
try:
data = json.loads((build_dir / _SENTINEL_NAME).read_text(encoding="utf-8"))
dirs = data.pop("dirs")
return (
data == _sentinel_state(build_dir)
and bool(dirs)
and all(Path(d).is_dir() for d in dirs)
)
except FileNotFoundError:
return False
except (OSError, ValueError, KeyError, AttributeError, TypeError):
_LOGGER.debug("Ignoring invalid prefetch sentinel", exc_info=True)
return False
def prefetch_platformio_packages() -> None:
"""Warm PlatformIO's download cache for the current project, in parallel."""
from esphome.core import CORE
from esphome.platformio.toolchain import (
default_libdeps_dir,
heal_platformio_python_env,
)
# Heal first: its Python-version wipe would discard freshly warmed
# caches and the sentinel's dirs (the later heal call is a no-op)
heal_platformio_python_env()
build_dir = Path(CORE.build_path)
if _prefetch_is_warm(build_dir):
return
# The child is esphome itself: PYTHONPATH stays so it imports this
# tree's esphome (tests/integration pins the source tree through it)
env = dict(os.environ)
# Must match run_platformio_cli's default or warm builds re-resolve
# every library
env.setdefault("PLATFORMIO_LIBDEPS_DIR", default_libdeps_dir())
# -v/-vv must reach the child's debug logging or the swallowed
# failure detail is undiagnosable in the field
env["ESPHOME_PREFETCH_LOG_LEVEL"] = str(logging.getLogger().getEffectiveLevel())
if CORE.dashboard:
# The child's progress bar and log escaping key off CORE.dashboard
env["ESPHOME_PREFETCH_DASHBOARD"] = "1"
cmd = [
sys.executable,
"-m",
"esphome.platformio.prefetch",
str(build_dir),
CORE.name,
]
try:
proc = subprocess.run(cmd, env=env, check=False, timeout=_PREFETCH_TIMEOUT)
except subprocess.TimeoutExpired:
_LOGGER.warning("PlatformIO package prefetch timed out; continuing without it")
return
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
# The prefetch must never become a new way for the build to fail
_LOGGER.warning("PlatformIO package prefetch skipped: %s", failure_reason(err))
_LOGGER.debug("Prefetch failure detail", exc_info=True)
return
if proc.returncode == _EXIT_HANDLED:
# The child already warned with the reason; a second line is noise
_LOGGER.debug("Prefetch child reported a handled failure")
elif proc.returncode != 0:
# Exit 1 stays here: the interpreter exits 1 for import/module
# failures before main() ever runs, a wiring break worth a warning
_LOGGER.warning(
"PlatformIO package prefetch skipped (exit %d)", proc.returncode
)
def _project_platform_and_config(ini: Path, env: str) -> tuple[str | None, Any]:
"""The env's platform spec and the ProjectConfig for the given ini."""
from platformio import app
from platformio.project.config import ProjectConfig
# PlatformBase.config reads the default ProjectConfig; it must see
# this ini's env options
app.set_session_var("custom_project_conf", str(ini))
config = ProjectConfig.get_instance(str(ini))
return config.get(f"env:{env}", "platform", None), config
def _registry_jobs(
manager, specs, seen: set[str]
) -> tuple[list[tuple[str, int, Any]], int]:
"""Resolve registry specs to ``(name, size, fetch)`` batch jobs.
Mirrors PlatformIO's install path: best version, systype file, first
mirror, and the same sha1(url + checksum) download-cache key. Also
returns how many resolutions errored (a clean skip is not an error).
"""
from platformio.registry.mirror import RegistryFileMirrorIterator
local = threading.local()
errors: list[str] = []
def _resolve(spec) -> tuple[str, int, str, Path, str] | object | None:
# One manager (and registry HTTP session) per worker thread;
# installed-state was already checked on the shared manager
if (mgr := getattr(local, "mgr", None)) is None:
mgr = local.mgr = manager.__class__()
try:
packages = mgr.search_registry_packages(spec)
if not packages:
_LOGGER.debug("%s is unknown to the registry", spec)
return None # let PlatformIO report it
package, version = mgr.find_best_registry_version(packages, spec)
if not package or not version:
_LOGGER.debug("%s has no matching registry version", spec)
return None
pkgfile = mgr.pick_compatible_pkg_file(version["files"])
if not pkgfile:
_LOGGER.debug("%s has no file for this systype", spec)
return None
url, checksum = next(RegistryFileMirrorIterator(pkgfile["download_url"]))
checksum = checksum or pkgfile["checksum"]["sha256"]
dl_path = Path(mgr.compute_download_path(url, checksum))
if dl_path.is_file():
return None # cached from an earlier run
size = pkgfile.get("size")
if not size:
_LOGGER.debug("%s has no size; PlatformIO fetches it", spec)
return None # no size, no bar share
return f"{package['name']}@{version['name']}", size, url, dl_path, checksum
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
# One flaky spec must not discard the rest of the batch
_LOGGER.debug("Could not resolve %s", spec, exc_info=True)
errors.append(failure_reason(err))
return _RESOLVE_FAILED
# Serial disk lookups on the shared manager: a fully warm build
# resolves nothing, and duplicate specs resolve once
unique: dict[tuple[str | None, str, str], Any] = {}
for s in specs:
if not s.uri and not manager.get_package(s):
unique.setdefault((s.owner, s.name, str(s.requirements)), s)
pending = list(unique.values())
if not pending:
return [], 0
# Serial resolutions (registry GET + mirror HEAD each) dominate
with ThreadPoolExecutor(max_workers=min(_RESOLVE_WORKERS, len(pending))) as ex:
results = list(ex.map(_resolve, pending))
jobs: list[tuple[str, int, Any]] = []
for res in results:
if res is None or res is _RESOLVE_FAILED:
continue
name, size, url, dl_path, checksum = res
if str(dl_path) in seen:
continue # duplicate spec; two workers must not share a .part
seen.add(str(dl_path))
jobs.append(
(name, size, _registry_fetch_job(manager, url, dl_path, checksum, size))
)
if failed := len(errors):
# Visible once per build, naming a cause so an API break does not
# read as an outage; per-spec detail stays at debug
_LOGGER.warning(
"Could not resolve %d of %d PlatformIO package(s) (%s); "
"PlatformIO will download them serially",
failed,
len(pending),
errors[0],
)
return jobs, failed
def _uri_jobs(manager, specs, seen: set[str]) -> tuple[list[tuple[str, int, Any]], int]:
"""Jobs for direct-URL specs; a HEAD sizes each for the combined bar.
Also returns how many HEAD probes errored (an absent length is not an
error).
"""
from esphome.net_retry import fetch_with_retry, http_request
candidates: list[tuple[str, str, Path]] = []
for spec in specs:
url = spec.uri
if not url or not url.startswith(("http://", "https://")):
continue # git+/file specs are cloned/copied, not downloaded
if url.split("#", 1)[0].endswith(".git"):
continue # bare-URL VCS spec; PlatformIO clones it
if manager.get_package(spec):
continue
# PlatformIO downloads URL specs with no checksum
dl_path = Path(manager.compute_download_path(url, ""))
if dl_path.is_file() or str(dl_path) in seen:
continue # cached, or another spec already claimed this .part
seen.add(str(dl_path))
candidates.append((spec.name, url, dl_path))
errors: list[str] = []
def _head_size(url: str) -> int:
try:
resp = fetch_with_retry(url, lambda: http_request("HEAD", url, timeout=30))
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
_LOGGER.debug("HEAD %s failed", url, exc_info=True)
errors.append(failure_reason(err))
return -1
if not resp.ok:
# An error page's Content-Length is not a download size
_LOGGER.debug("HEAD %s returned %s", url, resp.status_code)
if resp.status_code in (401, 403, 408, 429) or resp.status_code >= 500:
# 401/403 included: registries rate-limit with them
errors.append(f"HTTP {resp.status_code}")
return -1 # transient; must not be cached as warm
# Permanent (405/501 HEAD-unsupported, 401/403/404): a clean
# skip so the warm sentinel is not disabled forever; pio run
# surfaces a genuinely broken URL when it downloads
return 0
return content_length(resp)
if not candidates:
return [], 0
with ThreadPoolExecutor(max_workers=min(_RESOLVE_WORKERS, len(candidates))) as ex:
sizes = list(ex.map(_head_size, [url for _, url, _ in candidates]))
jobs: list[tuple[str, int, Any]] = []
failed = 0
for (name, url, dl_path), size in zip(candidates, sizes, strict=True):
if size < 0:
failed += 1
elif size:
jobs.append((name, size, _uri_fetch_job(manager, url, dl_path, size)))
else:
# Missing or unusable Content-Length; visible under -v
_LOGGER.debug("%s reports no usable length; PlatformIO fetches it", url)
if failed:
_LOGGER.warning(
"Could not size %d of %d PlatformIO package URL(s) (%s); "
"PlatformIO will download them serially",
failed,
len(candidates),
errors[0],
)
return jobs, failed
def _serialized_fetch_job(
dl_path: Path, lock_path: str, body: Any, unlocked_ok: bool = True
) -> Any:
"""Wrap ``body`` so the shared destination is single-writer.
Interleaved writers truncate each other's ``.part`` bytes (see
registry.py). The bounded poll observes Ctrl-C via the tracker; a
blown deadline is a clean skip (the holder's copy is what the build
needs). On a lock-less filesystem a sha256-verified body runs
unlocked with one warning; a checksum-less one
(``unlocked_ok=False``) is a counted failure instead.
"""
def run(tracker: Any) -> None:
from filelock import FileLock, Timeout
# fallback_to_soft would leave a stale marker on lock-less
# filesystems that blocks every later build (see git.py)
lock = FileLock(lock_path, fallback_to_soft=False)
deadline = time.monotonic() + _DOWNLOAD_LOCK_TIMEOUT
while True:
try:
lock.acquire(timeout=_URI_LOCK_POLL)
break
except Timeout:
tracker(0) # raises when the batch is cancelled
if time.monotonic() >= deadline:
# Another process is fetching this same file; its copy
# is what the build needs (a large framework archive
# can hold the lock far longer than this deadline)
_LOGGER.debug("Leaving %s to its current downloader", dl_path.name)
return
except OSError as err:
if not unlocked_ok:
# A body with no checksum to catch interleaved corruption
raise
lock = None
_LOGGER.warning(
"Could not lock %s (%s); downloading unlocked",
dl_path.name,
err,
)
break
try:
if dl_path.is_file():
return # another process finished it while we waited
body(tracker)
finally:
if lock is not None:
lock.release()
return run
# usage.db is a whole-file rewrite behind pio's self-unlinking LockFile;
# concurrent writers could reset every recorded entry
_REGISTER_LOCK = threading.Lock()
def _register_download(manager: Any, dl_path: Path) -> None:
"""Hand the archive to pio's usage.db pruner; an unregistered one is
never expired (disk garbage, never a bad build)."""
try:
with _REGISTER_LOCK:
manager.set_download_utime(str(dl_path))
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
_LOGGER.debug("Could not register %s with pio's cache: %s", dl_path, err)
def _registry_fetch_job(
manager: Any, url: str, dl_path: Path, checksum: str, size: int
) -> Any:
"""A locked fetch straight to the cache path; sha256 verifies it."""
# .esphome.lock: pio's own LockFile(dl_path) owns <dl_path>.lock and
# deletes it on release, which would unlink a held filelock
fetch = _serialized_fetch_job(
dl_path,
f"{dl_path}.esphome.lock",
resume_fetch_job(url, dl_path, sha256=checksum, size=size),
)
def run(tracker: Any) -> None:
fetch(tracker)
if dl_path.is_file():
# The deadline skip can end with no archive landed
_register_download(manager, dl_path)
return run
def _uri_fetch_job(manager: Any, url: str, dl_path: Path, size: int) -> Any:
"""Fetch to a locked staging path, then rename into the cache.
The stable staging name keeps resume working across interrupted
runs; the rename makes the promotion atomic.
"""
tmp = dl_path.with_name(f"{dl_path.name}.prefetch")
# attempts=2: the size is only a HEAD probe's word, and a HEAD/GET
# disagreement would otherwise re-download the archive five times
fetch = resume_fetch_job(url, tmp, size=size, attempts=2)
def promote(tracker: Any) -> None:
fetch(tracker)
if (actual := tmp.stat().st_size) != size:
# A wrong-length checksum-less body must never be published
discard_partial_download(tmp)
raise ValueError(f"expected {size} bytes, fetched {actual}")
tmp.replace(dl_path)
def run(tracker: Any) -> None:
_serialized_fetch_job(dl_path, f"{tmp}.lock", promote, unlocked_ok=False)(
tracker
)
if dl_path.is_file():
# Won or lost, the race is over; staging files left behind
# are dead weight PlatformIO's cache never prunes
discard_partial_download(tmp)
_register_download(manager, dl_path)
return run
def _prefetch(build_dir: Path, env: str) -> None:
from platformio.dependencies import get_core_dependencies
from platformio.package.manager.library import LibraryPackageManager
from platformio.package.manager.platform import PlatformPackageManager
from platformio.package.meta import PackageSpec
from platformio.platform.factory import PlatformFactory
platform_spec, config = _project_platform_and_config(
build_dir / "platformio.ini", env
)
if not platform_spec:
# An env mismatch must not disable the feature with no trace
_LOGGER.debug(
"No platform for env %s in %s; nothing to prefetch", env, build_dir
)
return
# The platform (manifest plus build scripts) installs first and
# resolves the rest. Its setup may rewrite sys.path (pioarduino's penv
# setup does); restore it so later imports here still resolve.
saved_sys_path = list(sys.path)
pm = PlatformPackageManager()
_sweep_stale_sidecars(Path(pm.get_download_dir()), pm.DOWNLOAD_CACHE_EXPIRE)
pkg = pm.install(platform_spec, skip_dependencies=True)
p = PlatformFactory.new(pkg)
p.configure_project_packages(env, ["run"])
sys.path[:] = saved_sys_path
specs = [
p.get_package_spec(name)
for name, opts in p.packages.items()
if not opts.get("optional")
]
# PIO's build engine installs outside the platform package list;
# skipped when the platform lists it itself
if not any(s.name == "tool-scons" for s in specs):
specs.append(
PackageSpec(
owner="platformio",
name="tool-scons",
requirements=get_core_dependencies()["tool-scons"],
)
)
lib_deps = config.get(f"env:{env}", "lib_deps", [])
# pio run's storage dir for this env: installed libraries skip by
# disk lookup
libdeps_dir = Path(config.get("platformio", "libdeps_dir")) / env
lm = LibraryPackageManager(str(libdeps_dir))
# A bare name is usually a framework built-in (WiFi, SPI); with no
# lib builders here to tell built-in from registry, skip it. The only
# cost is that an owner-less user library is not prefetched
lib_specs = [
spec
for dep in lib_deps
if dep and not dep.startswith("$")
if (spec := PackageSpec(dep)).external or spec.owner
]
seen: set[str] = set()
jobs: list[tuple[str, int, Any]] = []
unresolved = 0
for mgr, batch in ((p.pm, specs), (lm, lib_specs)):
for build_jobs in (_registry_jobs, _uri_jobs):
batch_jobs, failed = build_jobs(mgr, batch, seen)
jobs += batch_jobs
unresolved += failed
sentinel = build_dir / _SENTINEL_NAME
if not jobs:
if not unresolved:
# Record the no-work run so the parent skips the next spawn.
# A failed resolution is not "no work": a registry outage must
# not be cached as warm.
dirs = [config.get("platformio", "packages_dir")]
if lib_specs:
dirs.append(str(libdeps_dir))
sentinel.write_text(
json.dumps({**_sentinel_state(build_dir), "dirs": dirs}),
encoding="utf-8",
)
return
sentinel.unlink(missing_ok=True)
_LOGGER.info(
"Prefetching %d PlatformIO package(s): %s",
len(jobs),
", ".join(name for name, _, _ in jobs),
)
# PlatformIO retries failed packages itself, without resume
warn_prefetch_failures(run_batch_downloads("Downloading PlatformIO packages", jobs))
def main(argv: list[str]) -> int:
"""Subprocess entry point: ``prefetch <build_dir> <env_name>``."""
from esphome.core import CORE
from esphome.log import setup_log
raw_level = os.environ.get("ESPHOME_PREFETCH_LOG_LEVEL")
try:
level = int(raw_level) if raw_level is not None else logging.INFO
except ValueError:
level = logging.INFO
# Mirror the parent's log setup: warnings keep their level prefix and
# color, and the download bar still draws under the dashboard
CORE.dashboard = get_bool_env("ESPHOME_PREFETCH_DASHBOARD")
setup_log(level)
# pio's managers attach their own handler and still propagate; without
# this every manager line also prints through the root handler. Their
# construction re-pins the logger to INFO, so a logger-level filter
# (which survives pio's handler reset) enforces a quiet level instead.
for cls_name in (
"ToolPackageManager",
"LibraryPackageManager",
"PlatformPackageManager",
):
manager_logger = logging.getLogger(cls_name.replace("Package", " "))
manager_logger.propagate = False
manager_logger.addFilter(lambda record: record.levelno >= level)
if len(argv) != 2:
# A wiring bug, not a network failure; make it distinguishable
_LOGGER.warning("prefetch usage: <build_dir> <env_name>")
return 2
build_dir, env = argv
try:
_prefetch(Path(build_dir), env)
except KeyboardInterrupt:
# Shared process group: exit quietly, no traceback on the terminal
_LOGGER.debug("Prefetch interrupted", exc_info=True)
return 130
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
# The parent treats any exit as warn-and-continue, never a failure
_LOGGER.warning("PlatformIO package prefetch skipped: %s", failure_reason(err))
_LOGGER.debug("Prefetch failure detail", exc_info=True)
return _EXIT_HANDLED
return 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main(sys.argv[1:]))
+12 -5
View File
@@ -96,7 +96,7 @@ def _clean_platformio_python_env(config: "ProjectConfig", core_dir: Path) -> Non
rmtree(penv)
def _current_python_minor() -> str:
def current_python_minor() -> str:
"""Return the running interpreter's ``major.minor`` (e.g. ``3.13``)."""
return f"{sys.version_info.major}.{sys.version_info.minor}"
@@ -161,7 +161,7 @@ def heal_platformio_python_env() -> None:
def _check_platformio_python_stamp(config: "ProjectConfig") -> None:
"""Compare the stamp to the running interpreter; wipe and restamp on mismatch."""
current = _current_python_minor()
current = current_python_minor()
stamp_dir = _pio_stamp_dir(config)
# Host the stamp/lock even before PlatformIO's first run creates the dir.
stamp_dir.mkdir(parents=True, exist_ok=True)
@@ -289,6 +289,12 @@ def copy_ccache_script() -> None:
)
def default_libdeps_dir() -> str:
"""The PLATFORMIO_LIBDEPS_DIR value a pio run defaults to; the package
prefetch must resolve installed libraries against the same dir."""
return str(CORE.relative_piolibdeps_path().absolute())
def run_platformio_cli(*args, **kwargs) -> str | int:
# Re-provision the PlatformIO cache if the interpreter's major.minor changed
# since it was last built; a stale platform otherwise rejects the new Python
@@ -296,9 +302,7 @@ def run_platformio_cli(*args, **kwargs) -> str | int:
heal_platformio_python_env()
os.environ["PLATFORMIO_FORCE_COLOR"] = "true"
os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute())
os.environ.setdefault(
"PLATFORMIO_LIBDEPS_DIR", str(CORE.relative_piolibdeps_path().absolute())
)
os.environ.setdefault("PLATFORMIO_LIBDEPS_DIR", default_libdeps_dir())
# Suppress Python syntax warnings from third-party scripts during compilation
os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning")
# Increase uv retry count to handle transient network errors (default is 3)
@@ -346,6 +350,9 @@ def run_platformio_cli_run(config, verbose, *args, **kwargs) -> str | int:
def run_compile(config, verbose):
from esphome.platformio.prefetch import prefetch_platformio_packages
prefetch_platformio_packages()
args = []
if CONF_COMPILE_PROCESS_LIMIT in config[CONF_ESPHOME]:
args += [f"-j{config[CONF_ESPHOME][CONF_COMPILE_PROCESS_LIMIT]}"]
+10 -10
View File
@@ -911,7 +911,7 @@ def test_prefetch_leaves_unverifiable_entries_to_the_installer(
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.framework_helpers.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
):
@@ -934,7 +934,7 @@ def test_prefetch_all_entries_unverifiable_is_a_noop(tmp_path: Path) -> None:
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.framework_helpers.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
@@ -952,7 +952,7 @@ def test_prefetch_dedupes_entries_by_dest(tmp_path: Path) -> None:
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.framework_helpers.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress"),
):
@@ -967,7 +967,7 @@ def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None:
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.framework_helpers.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
):
@@ -1011,7 +1011,7 @@ def test_prefetch_downloads_archives_concurrently(tmp_path: Path) -> None:
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.framework_helpers.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch(
"esphome.framework_helpers.ThreadPoolExecutor", wraps=ThreadPoolExecutor
@@ -1032,7 +1032,7 @@ def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None:
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.framework_helpers.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
@@ -1065,7 +1065,7 @@ def test_prefetch_failures_never_raise(
with (
patch("esphome.espidf.framework.run_command", return_value=run_result),
patch(
"esphome.espidf.framework.download_with_resume",
"esphome.framework_helpers.download_with_resume",
side_effect=download_error,
),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
@@ -1087,7 +1087,7 @@ def test_prefetch_total_failure_logs_error(
return_value=(True, _PREFETCH_JSON, ""),
),
patch(
"esphome.espidf.framework.download_with_resume",
"esphome.framework_helpers.download_with_resume",
side_effect=OSError("proxy refuses everything"),
),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
@@ -1112,7 +1112,7 @@ def test_prefetch_one_failed_archive_does_not_stop_the_rest(
return_value=(True, _PREFETCH_JSON, ""),
),
patch(
"esphome.espidf.framework.download_with_resume",
"esphome.framework_helpers.download_with_resume",
side_effect=_fail_cmake_download,
) as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
@@ -1133,7 +1133,7 @@ def test_prefetch_finishes_progress_bar_and_cancels_queue(tmp_path: Path) -> Non
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch("esphome.espidf.framework.download_with_resume"),
patch("esphome.framework_helpers.download_with_resume"),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
patch("esphome.framework_helpers.ThreadPoolExecutor") as pool_cls,
@@ -2280,6 +2280,32 @@ class TestGetProjectCxxCompileFlags:
assert get_project_cxx_compile_flags() == []
def test_resume_fetch_job_threads_tracker(tmp_path: Path) -> None:
"""The batch runner passes the tracker positionally; the shared adapter
must deliver it as download_with_resume's progress keyword."""
from esphome.framework_helpers import resume_fetch_job
with patch("esphome.framework_helpers.download_with_resume") as mock_download:
fetch = resume_fetch_job("https://x/a.zip", tmp_path / "a", sha256="ff", size=9)
tracker = lambda done: None # noqa: E731
fetch(tracker)
mock_download.assert_called_once_with(
"https://x/a.zip", tmp_path / "a", progress=tracker, sha256="ff", size=9
)
def test_warn_prefetch_failures_names_each_failure(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The shared failure loop warns per job with the failure reason."""
from esphome.framework_helpers import warn_prefetch_failures
warn_prefetch_failures([("toolchain-x@1", OSError("down"))])
assert "Could not prefetch toolchain-x@1: down" in caplog.text
warn_prefetch_failures([("lib", OSError("gone"))], "Prefetch of %s failed: %s")
assert "Prefetch of lib failed: gone" in caplog.text
@pytest.mark.parametrize(
("platform", "input_path", "expected"),
[
@@ -2312,3 +2338,18 @@ def test_strip_win_long_path_prefix(
r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32."""
with patch("esphome.framework_helpers.sys.platform", platform):
assert framework_helpers.strip_win_long_path_prefix(input_path) == expected
def test_discard_partial_download_logs_undeletable(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unremovable staging file leaves a debug trace; the caller's
cache is never pruned, so silence would hide unbounded growth."""
dest = tmp_path / "archive"
dest.write_bytes(b"stale")
with (
patch.object(Path, "unlink", side_effect=OSError("busy")),
caplog.at_level(logging.DEBUG),
):
framework_helpers.discard_partial_download(dest)
assert "Could not remove" in caplog.text
@@ -0,0 +1,985 @@
"""Tests for the parallel PlatformIO package prefetch."""
import errno
import json
import os
from pathlib import Path
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from filelock import Timeout
import pytest
from esphome.core import CORE
import esphome.platformio.prefetch as pf
@pytest.fixture(autouse=True)
def _core(tmp_path: Path):
CORE.reset()
CORE.build_path = str(tmp_path)
CORE.name = "testenv"
pio_loggers = ("Tool Manager", "Library Manager", "Platform Manager")
saved_propagate = {n: pf.logging.getLogger(n).propagate for n in pio_loggers}
saved_filters = {n: list(pf.logging.getLogger(n).filters) for n in pio_loggers}
# The real setup_log would swap pytest's root-handler formatter
with patch("esphome.log.setup_log"):
yield
# main() flips these process-wide; keep the suite hermetic
for n, flag in saved_propagate.items():
pf.logging.getLogger(n).propagate = flag
pf.logging.getLogger(n).filters[:] = saved_filters[n]
CORE.reset()
class _FakeSpec(SimpleNamespace):
"""PackageSpec stand-in for the attributes the prefetch reads."""
def __init__(
self, *, owner=None, requirements=None, external=False, **kwargs
) -> None:
super().__init__(
owner=owner, requirements=requirements, external=external, **kwargs
)
def _fake_manager(tmp_path: Path) -> MagicMock:
m = MagicMock()
m.__class__ = lambda: m # _resolve constructs a same-class instance
m.get_package.return_value = None
m.search_registry_packages.return_value = [{"any": 1}]
m.find_best_registry_version.return_value = (
{"name": "toolchain-xtensa"},
{
"name": "2.0.0",
"files": [
{
"download_url": "https://dl.example/t.tar.gz",
"checksum": {"sha256": "cafe"},
"size": 1000,
}
],
},
)
m.pick_compatible_pkg_file.side_effect = lambda files: files[0]
m.compute_download_path.side_effect = lambda url, checksum: str(
tmp_path / "dl" / f"{abs(hash((url, checksum)))}"
)
return m
def _mirror_patch():
return patch.dict(
"sys.modules",
{
"platformio.registry.mirror": SimpleNamespace(
RegistryFileMirrorIterator=lambda url: iter(
[("https://mirror.example/t.tar.gz", "beef")]
)
)
},
)
def test_registry_jobs_resolves_like_platformio(tmp_path: Path) -> None:
"""A registry spec resolves to a job keyed by mirror URL and checksum."""
m = _fake_manager(tmp_path)
with _mirror_patch():
jobs, failed = pf._registry_jobs(
m, [_FakeSpec(uri=None, name="toolchain-xtensa")], set()
)
assert failed == 0
assert len(jobs) == 1
name, size, fetch = jobs[0]
assert name == "toolchain-xtensa@2.0.0"
assert size == 1000
m.compute_download_path.assert_called_once_with(
"https://mirror.example/t.tar.gz", "beef"
)
assert callable(fetch)
@pytest.mark.parametrize(
("method", "attr", "value"),
[
("get_package", "return_value", object()), # already installed
("search_registry_packages", "return_value", []), # unknown package
("find_best_registry_version", "return_value", (None, None)), # no match
("pick_compatible_pkg_file", "side_effect", lambda files: None), # no file
],
)
def test_registry_jobs_skips(tmp_path: Path, method, attr, value) -> None:
"""Entries PlatformIO would not download produce no job."""
m = _fake_manager(tmp_path)
setattr(getattr(m, method), attr, value)
with _mirror_patch():
assert pf._registry_jobs(m, [_FakeSpec(uri=None, name="x")], set()) == ([], 0)
def test_registry_jobs_skips_cached_and_sizeless(tmp_path: Path) -> None:
"""Cached or sizeless files are left to PlatformIO."""
m = _fake_manager(tmp_path)
dl = Path(m.compute_download_path("https://mirror.example/t.tar.gz", "beef"))
dl.parent.mkdir(parents=True, exist_ok=True)
dl.touch()
with _mirror_patch():
assert pf._registry_jobs(m, [_FakeSpec(uri=None, name="x")], set()) == ([], 0)
dl.unlink()
m.find_best_registry_version.return_value[1]["files"][0]["size"] = 0
with _mirror_patch():
assert pf._registry_jobs(m, [_FakeSpec(uri=None, name="x")], set()) == ([], 0)
def test_registry_jobs_dedupes_download_paths(tmp_path: Path) -> None:
"""Duplicate specs resolve once and one archive yields one job (two
workers must never share a .part); nine specs against eight workers
also exercise the thread-local manager reuse."""
m = _fake_manager(tmp_path)
specs = [_FakeSpec(uri=None, name="dup"), _FakeSpec(uri=None, name="dup")]
specs += [_FakeSpec(uri=None, name=f"n{i}") for i in range(8)]
with _mirror_patch():
jobs, failed = pf._registry_jobs(m, specs, set())
# the fake resolves every spec to the same mirror URL and checksum
assert failed == 0
assert len(jobs) == 1
assert m.search_registry_packages.call_count == 9 # dup resolved once
def test_registry_jobs_uri_specs_excluded(tmp_path: Path) -> None:
"""URL specs never reach the registry resolution."""
m = _fake_manager(tmp_path)
assert pf._registry_jobs(
m, [_FakeSpec(uri="https://x/y.zip", name="y")], set()
) == ([], 0)
m.search_registry_packages.assert_not_called()
def test_registry_jobs_dedup_keeps_distinct_owners(tmp_path: Path) -> None:
"""platformio/x and pioarduino/x are different packages."""
m = _fake_manager(tmp_path)
specs = [
_FakeSpec(uri=None, name="framework-x", owner="platformio"),
_FakeSpec(uri=None, name="framework-x", owner="pioarduino"),
]
with _mirror_patch():
pf._registry_jobs(m, specs, set())
assert m.search_registry_packages.call_count == 2
def test_registry_jobs_all_failed_warns_once(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A whole-batch failure is a systemic fault and must be visible."""
m = _fake_manager(tmp_path)
m.search_registry_packages.side_effect = RuntimeError("registry down")
with _mirror_patch():
jobs, failed = pf._registry_jobs(
m,
[_FakeSpec(uri=None, name="a"), _FakeSpec(uri=None, name="b")],
set(),
)
assert (jobs, failed) == ([], 2)
# The aggregate warning names a cause so an API break does not read
# as a registry outage
assert "Could not resolve 2 of 2" in caplog.text
assert "registry down" in caplog.text
def test_uri_fetch_job_promotes_atomically(tmp_path: Path) -> None:
"""Checksum-less URL archives land via a locked staging file and an
atomic rename (the stable name is what keeps .part resume working)."""
dl_path = tmp_path / "archive"
def fake_download(url, dest, progress=None, **kwargs):
Path(dest).write_bytes(b"data")
manager = MagicMock()
with patch(
"esphome.framework_helpers.download_with_resume", side_effect=fake_download
):
pf._uri_fetch_job(manager, "https://x/a.zip", dl_path, 4)(lambda done: None)
assert dl_path.read_bytes() == b"data"
# The archive is handed to pio's usage.db pruner
manager.set_download_utime.assert_called_once_with(str(dl_path))
# no orphaned staging file; the lock file may or may not persist
# (filelock removes it on release on some platforms)
leftovers = {f.name for f in tmp_path.iterdir()}
assert leftovers - {f"{dl_path.name}.prefetch.lock"} == {dl_path.name}
def test_registry_fetch_job_skips_when_cached(tmp_path: Path) -> None:
"""A destination another process completed is not re-downloaded."""
dl_path = tmp_path / "archive"
dl_path.write_bytes(b"done")
with patch("esphome.framework_helpers.download_with_resume") as mock_download:
pf._registry_fetch_job(
MagicMock(), "https://x/a.tar.gz", dl_path, "ab" * 32, 4
)(lambda done: None)
mock_download.assert_not_called()
assert dl_path.read_bytes() == b"done"
def test_registry_fetch_job_downloads_under_lock(tmp_path: Path) -> None:
"""Registry downloads write the shared cache path under the same lock
the URL path uses; interleaved writers would corrupt the archive."""
dl_path = tmp_path / "archive"
order: list[str] = []
with (
patch(
"esphome.framework_helpers.download_with_resume",
side_effect=lambda url, dest, progress=None, **kw: (
order.append("fetch"),
Path(dest).write_bytes(b"data"), # registration needs a real file
),
),
patch(
"filelock.FileLock.acquire",
side_effect=lambda *a, **k: order.append("lock"),
),
patch(
"filelock.FileLock.release",
side_effect=lambda *a, **k: order.append("unlock"),
),
):
manager = MagicMock()
pf._registry_fetch_job(manager, "https://x/a.tar.gz", dl_path, "ab" * 32, 4)(
lambda done: None
)
# FileLock.__del__ may add a trailing release; the contract is the order
assert order[:2] == ["lock", "fetch"]
assert "unlock" in order[2:]
manager.set_download_utime.assert_called_once_with(str(dl_path))
def test_lockless_filesystem_downloads_unlocked(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A filesystem without lock support (ENOSYS/EPERM) degrades to an
unlocked download with one warning, never a per-package failure."""
dl_path = tmp_path / "archive"
with (
patch("esphome.framework_helpers.download_with_resume") as mock_download,
patch(
"filelock.FileLock.acquire",
side_effect=OSError(errno.ENOSYS, "no locks"),
),
patch("filelock.FileLock.release"),
):
pf._registry_fetch_job(
MagicMock(), "https://x/a.tar.gz", dl_path, "ab" * 32, 4
)(lambda done: None)
mock_download.assert_called_once()
assert "downloading unlocked" in caplog.text
def test_uri_fetch_job_failed_download_keeps_staging(tmp_path: Path) -> None:
"""A failed fetch keeps the .part staging bytes for the next resume."""
dl_path = tmp_path / "archive"
part = tmp_path / "archive.prefetch.part"
part.write_bytes(b"partial")
with (
patch(
"esphome.framework_helpers.download_with_resume",
side_effect=OSError("network gone"),
),
pytest.raises(OSError, match="network gone"),
):
pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(lambda done: None)
assert part.read_bytes() == b"partial"
assert not dl_path.exists()
def test_uri_fetch_job_rejects_wrong_length(tmp_path: Path) -> None:
"""A checksum-less body of the wrong length is never published under a
cache key pio would trust forever."""
dl_path = tmp_path / "archive"
def fake_download(url, dest, progress=None, **kwargs):
Path(dest).write_bytes(b"short")
with (
patch(
"esphome.framework_helpers.download_with_resume", side_effect=fake_download
),
pytest.raises(ValueError, match="expected 9999 bytes"),
):
pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 9999)(
lambda done: None
)
assert not dl_path.exists()
assert not (tmp_path / "archive.prefetch").exists()
def test_sweep_stale_sidecars(tmp_path: Path) -> None:
"""Sidecars past pio's own expiry are pruned; fresh and foreign files
stay."""
old_time = pf.time.time() - 110
stale = tmp_path / "a.tar.gz.part"
stale.write_bytes(b"x")
os.utime(stale, (old_time, old_time))
fresh = tmp_path / "b.tar.gz.part"
fresh.write_bytes(b"x")
keep = tmp_path / "c.tar.gz"
keep.write_bytes(b"x")
os.utime(keep, (old_time, old_time))
# A held lock can carry an ancient mtime (O_TRUNC keeps it); locks
# must never be swept or the single-writer guarantee reopens
held_lock = tmp_path / "d.tar.gz.esphome.lock"
held_lock.write_bytes(b"")
os.utime(held_lock, (old_time, old_time))
pf._sweep_stale_sidecars(tmp_path, 100)
assert not stale.exists()
assert fresh.exists()
assert keep.exists()
assert held_lock.exists()
pf._sweep_stale_sidecars(tmp_path / "missing", 100) # tolerated
def test_register_download_failure_leaves_a_trace(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A failed usage.db registration is traced; an unregistered archive
is never pruned, so silence would hide the leak coming back."""
manager = MagicMock()
manager.set_download_utime.side_effect = RuntimeError("db locked")
with caplog.at_level(pf.logging.DEBUG):
pf._register_download(manager, tmp_path / "a.tar.gz")
assert "Could not register" in caplog.text
def test_sweep_logs_unprunable_files(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A sidecar that cannot be removed leaves a trace; a sweep that never
prunes must not look like a clean sweep."""
old_time = pf.time.time() - 110
stale = tmp_path / "a.tar.gz.part"
stale.write_bytes(b"x")
os.utime(stale, (old_time, old_time))
with (
patch.object(Path, "unlink", side_effect=OSError("busy")),
caplog.at_level(pf.logging.DEBUG),
):
pf._sweep_stale_sidecars(tmp_path, 100)
assert "Could not remove" in caplog.text
def test_uri_lock_failure_is_a_counted_failure(tmp_path: Path) -> None:
"""The checksum-less URL path never degrades to an unlocked shared
write; interleaved right-length corruption would go undetected."""
dl_path = tmp_path / "archive"
with (
patch("esphome.framework_helpers.download_with_resume") as mock_download,
patch(
"filelock.FileLock.acquire",
side_effect=OSError(errno.ENOSYS, "no locks"),
),
pytest.raises(OSError),
):
pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(lambda done: None)
mock_download.assert_not_called()
def test_uri_fetch_job_no_discard_without_a_file(tmp_path: Path) -> None:
"""When no archive landed (degraded serialized run), the staging bytes
stay for the next resume instead of being discarded."""
dl_path = tmp_path / "archive"
part = tmp_path / "archive.prefetch.part"
part.write_bytes(b"partial")
with patch.object(pf, "_serialized_fetch_job", return_value=lambda tracker: None):
pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(lambda done: None)
assert part.read_bytes() == b"partial"
def test_uri_fetch_job_waits_out_a_briefly_held_lock(tmp_path: Path) -> None:
"""A lock freed within the deadline lets the job proceed normally."""
dl_path = tmp_path / "archive"
def fake_download(url, dest, progress=None, **kwargs):
Path(dest).write_bytes(b"data")
with (
patch(
"esphome.framework_helpers.download_with_resume", side_effect=fake_download
),
patch("filelock.FileLock.acquire", side_effect=[Timeout("held"), None]),
patch("filelock.FileLock.release"),
):
pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(lambda done: None)
assert dl_path.read_bytes() == b"data"
def test_lock_deadline_leaves_download_to_the_holder(tmp_path: Path) -> None:
"""A lock held past the deadline means another process is fetching the
same file; skipping cleanly beats a misleading failure warning. The
tracker is still polled so a parked worker observes cancellation."""
dl_path = tmp_path / "archive"
ticks: list[int] = []
with (
patch("esphome.framework_helpers.download_with_resume") as mock_download,
patch("filelock.FileLock.acquire", side_effect=Timeout("held")),
patch.object(pf, "_DOWNLOAD_LOCK_TIMEOUT", 0),
):
pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(ticks.append)
mock_download.assert_not_called()
assert ticks == [0]
assert not dl_path.exists()
def test_registry_lock_deadline_skips_registration(tmp_path: Path) -> None:
"""A registry job that lost the download race to another process
must not stamp a nonexistent archive into pio's usage.db."""
manager = MagicMock()
dl_path = tmp_path / "archive"
with (
patch("esphome.framework_helpers.download_with_resume") as mock_download,
patch("filelock.FileLock.acquire", side_effect=Timeout("held")),
patch.object(pf, "_DOWNLOAD_LOCK_TIMEOUT", 0),
):
pf._registry_fetch_job(manager, "https://x/a.tar.gz", dl_path, "ab" * 32, 4)(
lambda done: None
)
mock_download.assert_not_called()
manager.set_download_utime.assert_not_called()
def test_main_interrupt_exits_quietly(tmp_path: Path) -> None:
"""Ctrl-C reaches the child via the shared process group; it must exit
without a traceback."""
with (
patch("esphome.log.setup_log"),
patch.object(pf, "_prefetch", side_effect=KeyboardInterrupt),
):
assert pf.main([str(tmp_path), "testenv"]) == 130
def test_main_bad_log_level_falls_back(tmp_path: Path) -> None:
with (
patch.dict("os.environ", {"ESPHOME_PREFETCH_LOG_LEVEL": "verbose"}),
patch("esphome.log.setup_log") as mock_setup,
patch.object(pf, "_prefetch"),
):
assert pf.main([str(tmp_path), "testenv"]) == 0
assert mock_setup.call_args[0][0] == pf.logging.INFO
def test_main_silences_pio_manager_propagation(tmp_path: Path) -> None:
"""The pio manager loggers carry their own handler; propagation to
the root handler would print every install line twice."""
with patch("esphome.log.setup_log"), patch.object(pf, "_prefetch"):
assert pf.main([str(tmp_path), "testenv"]) == 0
for name in ("Tool Manager", "Library Manager", "Platform Manager"):
assert pf.logging.getLogger(name).propagate is False
def test_main_quiet_level_reaches_pio_manager_loggers(tmp_path: Path) -> None:
"""Manager construction re-pins its logger to INFO, so a quiet run
needs the logger-level filter to keep per-package lines out."""
with (
patch.dict("os.environ", {"ESPHOME_PREFETCH_LOG_LEVEL": "30"}),
patch("esphome.log.setup_log"),
patch.object(pf, "_prefetch"),
):
assert pf.main([str(tmp_path), "testenv"]) == 0
lib_logger = pf.logging.getLogger("Library Manager")
lib_logger.setLevel(pf.logging.INFO) # what pio's _setup_logger does
info = pf.logging.LogRecord("Library Manager", 20, __file__, 1, "x", (), None)
warning = pf.logging.LogRecord("Library Manager", 30, __file__, 1, "x", (), None)
# Logger.filter returns falsy to drop, the record itself to pass
assert not lib_logger.filter(info)
assert lib_logger.filter(warning)
def test_main_mirrors_parent_log_setup(tmp_path: Path) -> None:
"""The child adopts the parent's dashboard flag and log formatter so
its warnings and progress bar match the parent's."""
with (
patch.dict(
"os.environ",
{"ESPHOME_PREFETCH_LOG_LEVEL": "30", "ESPHOME_PREFETCH_DASHBOARD": "1"},
),
patch("esphome.log.setup_log") as mock_setup,
patch.object(pf, "_prefetch"),
):
assert pf.main([str(tmp_path), "testenv"]) == 0
mock_setup.assert_called_once_with(30)
assert CORE.dashboard is True
def test_uri_fetch_job_skips_when_another_process_won(tmp_path: Path) -> None:
"""A lost race discards the staging files; the cache never prunes them."""
dl_path = tmp_path / "archive"
dl_path.write_bytes(b"done")
stale = [
tmp_path / "archive.prefetch",
tmp_path / "archive.prefetch.part",
tmp_path / "archive.prefetch.part.meta",
]
for f in stale:
f.write_bytes(b"stale")
with patch("esphome.framework_helpers.download_with_resume") as mock_download:
pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(lambda done: None)
mock_download.assert_not_called()
assert dl_path.read_bytes() == b"done"
assert not any(f.exists() for f in stale)
def test_registry_jobs_one_bad_spec_keeps_the_rest(tmp_path: Path) -> None:
"""A flaky resolution counts as failed without discarding the batch."""
m = _fake_manager(tmp_path)
m.search_registry_packages.side_effect = [
RuntimeError("registry 500"),
[{"any": 1}],
]
with _mirror_patch():
jobs, failed = pf._registry_jobs(
m,
[_FakeSpec(uri=None, name="flaky"), _FakeSpec(uri=None, name="good")],
set(),
)
assert failed == 1
assert len(jobs) == 1
def test_uri_jobs_head_sizes_the_bar(tmp_path: Path) -> None:
"""HEAD sizes direct-URL specs; git and unreachable URLs are skipped."""
m = _fake_manager(tmp_path)
resp = MagicMock()
resp.headers = {"content-length": "2222"}
with patch("esphome.net_retry.http_request", return_value=resp):
jobs, failed = pf._uri_jobs(
m,
[
_FakeSpec(uri="https://x/big.zip", name="big"),
_FakeSpec(uri="git+https://x/repo.git", name="repo"),
_FakeSpec(uri="https://x/repo.git#v1", name="barevcs"),
_FakeSpec(uri=None, name="registry"),
],
set(),
)
assert failed == 0
assert [(n, s) for n, s, _ in jobs] == [("big", 2222)]
# a successful HEAD with no Content-Length is a clean skip
resp.headers = {}
with patch("esphome.net_retry.http_request", return_value=resp):
assert pf._uri_jobs(
m, [_FakeSpec(uri="https://x/nolen.zip", name="nolen")], set()
) == ([], 0)
def test_uri_jobs_head_failure_counts_as_unresolved(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Network errors and transient statuses count as unresolved (and warn);
any permanent error status is a clean skip so the sentinel can still
be written (pio run names a broken URL when it downloads)."""
m = _fake_manager(tmp_path)
spec = [_FakeSpec(uri="https://x/a.zip", name="a")]
with patch("esphome.net_retry.http_request", side_effect=OSError("no route")):
assert pf._uri_jobs(m, spec, set()) == ([], 1)
resp = MagicMock(ok=False, status_code=503)
resp.headers = {"content-length": "999"}
with patch("esphome.net_retry.http_request", return_value=resp):
assert pf._uri_jobs(m, spec, set()) == ([], 1)
# 403 is how registries rate-limit; it must not be cached as warm
resp = MagicMock(ok=False, status_code=403)
resp.headers = {"content-length": "999"}
with patch("esphome.net_retry.http_request", return_value=resp):
assert pf._uri_jobs(m, spec, set()) == ([], 1)
resp = MagicMock(ok=False, status_code=405)
resp.headers = {"content-length": "999"}
with patch("esphome.net_retry.http_request", return_value=resp):
assert pf._uri_jobs(m, spec, set()) == ([], 0)
assert "HEAD https://x/a.zip" not in caplog.text
resp = MagicMock(ok=False, status_code=404)
resp.headers = {"content-length": "999"}
with patch("esphome.net_retry.http_request", return_value=resp):
assert pf._uri_jobs(m, spec, set()) == ([], 0)
assert "returned 404" not in caplog.text
def test_uri_jobs_dedupes_duplicate_urls(tmp_path: Path) -> None:
"""Two specs with one URL yield one HEAD and one job."""
m = _fake_manager(tmp_path)
resp = MagicMock()
resp.headers = {"content-length": "5"}
with patch("esphome.net_retry.http_request", return_value=resp) as mock_head:
jobs, failed = pf._uri_jobs(
m,
[
_FakeSpec(uri="https://x/a.zip", name="a"),
_FakeSpec(uri="https://x/a.zip", name="a"),
],
set(),
)
assert failed == 0
assert len(jobs) == 1
mock_head.assert_called_once()
def test_uri_jobs_skips_installed_cached_and_seen(tmp_path: Path) -> None:
m = _fake_manager(tmp_path)
m.get_package.return_value = object()
spec = [_FakeSpec(uri="https://x/a.zip", name="a")]
assert pf._uri_jobs(m, spec, set()) == ([], 0)
m.get_package.return_value = None
dl = Path(m.compute_download_path("https://x/a.zip", ""))
dl.parent.mkdir(parents=True, exist_ok=True)
dl.touch()
assert pf._uri_jobs(m, spec, set()) == ([], 0)
dl.unlink()
# a registry job already claimed this download path
assert pf._uri_jobs(m, spec, {str(dl)}) == ([], 0)
def test_prefetch_spawns_isolated_subprocess(tmp_path: Path) -> None:
"""Heal runs first, then the subprocess spawns with pio run's libdeps
dir and the parent's PYTHONPATH preserved (the child is esphome)."""
proc = MagicMock(returncode=0)
order = MagicMock()
order.run.return_value = proc
with (
patch(
"esphome.platformio.toolchain.heal_platformio_python_env",
order.heal,
),
patch.object(pf.subprocess, "run", order.run) as mock_run,
patch.dict("os.environ", {"PYTHONPATH": "/leak"}),
):
pf.prefetch_platformio_packages()
assert [c[0] for c in order.mock_calls[:2]] == ["heal", "run"]
(cmd,), kwargs = mock_run.call_args
assert cmd == [
sys.executable,
"-m",
"esphome.platformio.prefetch",
str(CORE.build_path),
"testenv",
]
assert kwargs["env"]["PLATFORMIO_LIBDEPS_DIR"] == str(
CORE.relative_piolibdeps_path().absolute()
)
# The child is esphome itself; PYTHONPATH must survive so it imports
# the same tree (tests/integration pins the source tree through it)
assert kwargs["env"]["PYTHONPATH"] == "/leak"
assert "ESPHOME_PREFETCH_DASHBOARD" not in kwargs["env"]
assert kwargs["timeout"] == pf._PREFETCH_TIMEOUT
def test_prefetch_passes_dashboard_flag(tmp_path: Path) -> None:
"""The dashboard flag reaches the child so its bar still draws."""
CORE.dashboard = True
with (
patch("esphome.platformio.toolchain.heal_platformio_python_env"),
patch.object(
pf.subprocess, "run", return_value=MagicMock(returncode=0)
) as mock_run,
):
pf.prefetch_platformio_packages()
assert mock_run.call_args[1]["env"]["ESPHOME_PREFETCH_DASHBOARD"] == "1"
@pytest.mark.parametrize(
("run_effect", "expected"),
[
(
{"side_effect": pf.subprocess.TimeoutExpired("cmd", pf._PREFETCH_TIMEOUT)},
"prefetch timed out",
),
({"return_value": MagicMock(returncode=4)}, "prefetch skipped (exit 4)"),
# Exit 1 is the interpreter's own import-failure code, never quiet
({"return_value": MagicMock(returncode=1)}, "prefetch skipped (exit 1)"),
({"side_effect": OSError("no exec")}, "PlatformIO package prefetch skipped"),
],
)
def test_prefetch_spawn_failures_warn_and_continue(
caplog: pytest.LogCaptureFixture, run_effect, expected
) -> None:
"""Timeouts, nonzero exits, and spawn failures each warn, never raise."""
with (
patch("esphome.platformio.toolchain.heal_platformio_python_env"),
patch.object(pf.subprocess, "run", **run_effect),
):
pf.prefetch_platformio_packages()
assert expected in caplog.text
def test_prefetch_child_handled_failure_is_quiet(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Exit _EXIT_HANDLED (3) means the child already warned with the
reason; the parent adds no second warning."""
with (
patch("esphome.platformio.toolchain.heal_platformio_python_env"),
patch.object(
pf.subprocess, "run", return_value=MagicMock(returncode=pf._EXIT_HANDLED)
),
):
pf.prefetch_platformio_packages()
assert "prefetch skipped" not in caplog.text
def test_main_guards_and_exits_nonzero(caplog: pytest.LogCaptureFixture) -> None:
"""A swallowed failure still reaches the parent as a nonzero exit; the
parent warns and continues, never failing the build."""
with patch.object(pf, "_prefetch", side_effect=RuntimeError("boom")):
assert pf.main(["/b", "testenv"]) == pf._EXIT_HANDLED
assert "PlatformIO package prefetch skipped" in caplog.text
def test_main_runs_prefetch(tmp_path: Path) -> None:
with patch.object(pf, "_prefetch") as mock_prefetch:
assert pf.main([str(tmp_path), "testenv"]) == 0
mock_prefetch.assert_called_once_with(tmp_path, "testenv")
def test_main_bad_argv_is_a_distinct_exit(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A parent/child wiring bug must not look like a network failure."""
with patch.object(pf, "_prefetch") as mock_prefetch:
assert pf.main(["only-one"]) == 2
mock_prefetch.assert_not_called()
assert "prefetch usage" in caplog.text
def _write_ini(tmp_path: Path, body: str) -> None:
(tmp_path / "platformio.ini").write_text(body)
def _write_valid_sentinel(tmp_path: Path, dirs: list[str]) -> None:
(tmp_path / pf._SENTINEL_NAME).write_text(
json.dumps({**pf._sentinel_state(tmp_path), "dirs": dirs}), encoding="utf-8"
)
def test_prefetch_no_platform_returns(tmp_path: Path) -> None:
_write_ini(tmp_path, "[env:testenv]\n")
with patch.object(pf, "_registry_jobs") as mock_jobs:
pf._prefetch(tmp_path, "testenv")
mock_jobs.assert_not_called()
def _pio_modules(tmp_path: Path, fake_platform, fake_pm, config, lib_captures=None):
# A bare MagicMock's get_download_dir would fspath to '' and point the
# sidecar sweep at the process cwd
fake_pm.get_download_dir.return_value = str(tmp_path / "downloads")
fake_pm.DOWNLOAD_CACHE_EXPIRE = 86400 * 30
def fake_lib_manager(storage_dir):
if lib_captures is not None:
lib_captures.append(storage_dir)
return _fake_manager(tmp_path)
modules = {
"platformio": MagicMock(),
"platformio.app": MagicMock(),
"platformio.project": MagicMock(),
"platformio.project.config": MagicMock(),
"platformio.dependencies": SimpleNamespace(
get_core_dependencies=lambda: {
"tool-scons": "~4.0",
"contrib-piohome": "~3",
}
),
"platformio.package": MagicMock(),
"platformio.package.manager": MagicMock(),
"platformio.package.manager.library": SimpleNamespace(
LibraryPackageManager=fake_lib_manager
),
"platformio.package.manager.platform": SimpleNamespace(
PlatformPackageManager=lambda: fake_pm
),
"platformio.package.meta": SimpleNamespace(
PackageSpec=lambda *a, **kw: _FakeSpec(
uri=None,
name=kw.get("name") or (a[0] if a else None),
owner=kw.get("owner")
or (str(a[0]).split("/")[0] if a and "/" in str(a[0]) else None),
external=bool(a and "://" in str(a[0])),
)
),
"platformio.platform": MagicMock(),
"platformio.platform.factory": SimpleNamespace(
PlatformFactory=SimpleNamespace(new=lambda pkg: fake_platform)
),
}
modules[
"platformio.project.config"
].ProjectConfig.get_instance.return_value = config
return modules
def _fake_config(tmp_path: Path, env_options: dict):
config = MagicMock()
options = {
"libdeps_dir": str(tmp_path / "libdeps"),
"packages_dir": str(tmp_path / "packages"),
**env_options,
}
config.get.side_effect = lambda section, key, default=None: options.get(
key, default
)
return config
def test_prefetch_all_cached_is_quiet_and_writes_sentinel(tmp_path: Path) -> None:
"""A no-work run neither logs nor batches, and records the sentinel."""
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
(tmp_path / "packages").mkdir()
(tmp_path / "libdeps" / "testenv").mkdir(parents=True)
fake_platform = MagicMock()
fake_platform.packages = {}
config = _fake_config(
tmp_path, {"platform": "fake/p@1", "lib_deps": ["esphome/noise-c@1.0"]}
)
modules = _pio_modules(tmp_path, fake_platform, MagicMock(), config)
with (
patch.dict("sys.modules", modules),
patch.object(pf, "_registry_jobs", return_value=([], 0)),
patch.object(pf, "_uri_jobs", return_value=([], 0)),
patch.object(pf, "run_batch_downloads") as mock_batch,
):
pf._prefetch(tmp_path, "testenv")
mock_batch.assert_not_called()
assert pf._prefetch_is_warm(tmp_path)
def test_prefetch_failed_resolution_is_not_cached_as_warm(tmp_path: Path) -> None:
"""A registry outage must not write the sentinel."""
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
(tmp_path / "packages").mkdir()
fake_platform = MagicMock()
fake_platform.packages = {}
config = _fake_config(tmp_path, {"platform": "fake/p@1"})
modules = _pio_modules(tmp_path, fake_platform, MagicMock(), config)
with (
patch.dict("sys.modules", modules),
patch.object(pf, "_registry_jobs", return_value=([], 1)),
patch.object(pf, "_uri_jobs", return_value=([], 0)),
patch.object(pf, "run_batch_downloads") as mock_batch,
):
pf._prefetch(tmp_path, "testenv")
mock_batch.assert_not_called()
assert not (tmp_path / pf._SENTINEL_NAME).exists()
def test_sentinel_invalidation(tmp_path: Path) -> None:
"""Ini changes, missing dirs, and garbage sentinels all read as cold."""
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
pkg_dir = tmp_path / "packages"
pkg_dir.mkdir()
assert not pf._prefetch_is_warm(tmp_path) # no sentinel yet
_write_valid_sentinel(tmp_path, [str(pkg_dir)])
assert pf._prefetch_is_warm(tmp_path)
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@2\n")
assert not pf._prefetch_is_warm(tmp_path) # ini changed
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
pkg_dir.rmdir()
assert not pf._prefetch_is_warm(tmp_path) # recorded dir gone
(tmp_path / pf._SENTINEL_NAME).write_text("not json", encoding="utf-8")
assert not pf._prefetch_is_warm(tmp_path)
def test_prefetch_warm_sentinel_skips_spawn(tmp_path: Path) -> None:
"""A valid sentinel skips the subprocess entirely."""
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
pkg_dir = tmp_path / "packages"
pkg_dir.mkdir()
_write_valid_sentinel(tmp_path, [str(pkg_dir)])
with (
patch("esphome.platformio.toolchain.heal_platformio_python_env"),
patch.object(pf.subprocess, "run") as mock_run,
):
pf.prefetch_platformio_packages()
mock_run.assert_not_called()
def test_prefetch_end_to_end_wiring(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Platform installs dep-free, non-optional packages plus tool-scons
resolve, libraries use the env libdeps dir, a platform sys.path rewrite
is undone, and failures warn by name."""
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/platform@1.0\n")
fake_platform = MagicMock()
fake_platform.packages = {
"toolchain-x": {"optional": False},
"framework-y": {"optional": True},
}
fake_platform.get_package_spec.side_effect = lambda name: _FakeSpec(
uri=None, name=name
)
# Platform setup code rewrites sys.path (pioarduino penv); _prefetch
# must restore it
bogus = str(tmp_path / "penv-site-packages")
fake_platform.configure_project_packages.side_effect = lambda env, targets: (
sys.path.insert(0, bogus)
)
fake_pm = MagicMock()
config = _fake_config(
tmp_path,
{
"platform": "fake/platform@1.0",
# the bare built-in name and the interpolation are skipped;
# only the owner-qualified library resolves
"lib_deps": ["esphome/noise-c@1.0", "WiFi", "${common.lib_deps}"],
},
)
lib_dirs: list[str] = []
modules = _pio_modules(tmp_path, fake_platform, fake_pm, config, lib_dirs)
(tmp_path / pf._SENTINEL_NAME).write_text("{}", encoding="utf-8")
captured: dict = {}
def fake_registry_jobs(manager, specs, seen):
captured.setdefault("spec_batches", []).append([s.name for s in specs])
return [("toolchain-x@1", 10, lambda t: None)], 0
with (
patch.dict("sys.modules", modules),
patch.object(pf, "_registry_jobs", side_effect=fake_registry_jobs),
patch.object(pf, "_uri_jobs", return_value=([], 0)),
patch.object(
pf,
"run_batch_downloads",
return_value=[("toolchain-x@1", OSError("down"))],
) as mock_batch,
):
pf._prefetch(tmp_path, "testenv")
fake_pm.install.assert_called_once_with("fake/platform@1.0", skip_dependencies=True)
assert not (tmp_path / pf._SENTINEL_NAME).exists() # stale sentinel removed
fake_platform.configure_project_packages.assert_called_once_with("testenv", ["run"])
assert bogus not in sys.path
# non-optional platform package + tool-scons (never piohome), then libs
assert captured["spec_batches"][0] == ["toolchain-x", "tool-scons"]
assert captured["spec_batches"][1] == ["esphome/noise-c@1.0"]
assert lib_dirs == [str(Path(tmp_path / "libdeps") / "testenv")]
mock_batch.assert_called_once()
assert "Could not prefetch toolchain-x@1" in caplog.text
def test_prefetch_skips_duplicate_tool_scons(tmp_path: Path) -> None:
"""A platform that lists tool-scons itself does not get it appended."""
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
fake_platform = MagicMock()
fake_platform.packages = {"tool-scons": {"optional": False}}
fake_platform.get_package_spec.side_effect = lambda name: _FakeSpec(
uri=None, name=name
)
config = _fake_config(tmp_path, {"platform": "fake/p@1"})
modules = _pio_modules(tmp_path, fake_platform, MagicMock(), config)
batches: list[list[str]] = []
with (
patch.dict("sys.modules", modules),
patch.object(
pf,
"_registry_jobs",
side_effect=lambda mgr, specs, seen: (
batches.append([s.name for s in specs]) or ([], 0)
),
),
patch.object(pf, "_uri_jobs", return_value=([], 0)),
):
pf._prefetch(tmp_path, "testenv")
assert batches[0] == ["tool-scons"]
+10 -4
View File
@@ -932,8 +932,13 @@ def test_run_compile(setup_core: Path, mock_run_platformio_cli_run: Mock) -> Non
config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}}
mock_run_platformio_cli_run.return_value = 0
toolchain.run_compile(config, verbose=True)
with patch(
"esphome.platformio.prefetch.prefetch_platformio_packages"
) as mock_prefetch:
toolchain.run_compile(config, verbose=True)
# The only wiring of the prefetch into a build lives here
mock_prefetch.assert_called_once_with()
mock_run_platformio_cli_run.assert_called_once_with(config, True, "-j4")
@@ -947,7 +952,8 @@ def test_run_compile_without_process_limit(
config = {CONF_ESPHOME: {}}
mock_run_platformio_cli_run.return_value = 0
toolchain.run_compile(config, verbose=False)
with patch("esphome.platformio.prefetch.prefetch_platformio_packages"):
toolchain.run_compile(config, verbose=False)
mock_run_platformio_cli_run.assert_called_once_with(config, False)
@@ -1677,8 +1683,8 @@ def pio_core_dir(tmp_path: Path) -> Path:
def test_current_python_minor_matches_running_interpreter() -> None:
"""_current_python_minor returns major.minor of the running interpreter."""
assert toolchain._current_python_minor() == _CURRENT_MINOR
"""current_python_minor returns major.minor of the running interpreter."""
assert toolchain.current_python_minor() == _CURRENT_MINOR
def test_pio_stamp_round_trip(tmp_path: Path) -> None: