mirror of
https://github.com/esphome/esphome.git
synced 2026-08-28 00:33:31 +00:00
378 lines
15 KiB
Python
Executable File
378 lines
15 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# This script is used to preinstall
|
|
# all platformio libraries in the global storage
|
|
|
|
import argparse
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
import configparser
|
|
from contextlib import suppress
|
|
import os
|
|
from pathlib import Path
|
|
import queue
|
|
import subprocess
|
|
import threading
|
|
import traceback
|
|
|
|
# esphome is not installed at this docker layer; pio's fs.rmtree is the
|
|
# same chmod-on-readonly shape its own installer uses
|
|
try:
|
|
from platformio import fs
|
|
from platformio.cache import ContentCache
|
|
from platformio.package.manager.base import BasePackageManager
|
|
from platformio.package.manager.library import LibraryPackageManager
|
|
from platformio.package.manager.tool import ToolPackageManager
|
|
from platformio.package.meta import PackageCompatibility
|
|
|
|
PARALLEL_AVAILABLE = True
|
|
except ImportError as err: # pragma: no cover
|
|
# A moved pio module must degrade to the serial pass, not kill the
|
|
# image build; the tripwire test makes the drift loud in CI
|
|
PARALLEL_AVAILABLE = False
|
|
IMPORT_ERROR = repr(err)
|
|
|
|
# Network-bound downloads release the GIL, so the pool oversubscribes
|
|
# the cores. This bypasses pio's 500ms registry throttle and races its
|
|
# self-unlinking cache LockFiles; both are cache-only and self-healing.
|
|
MAX_WORKERS = 16
|
|
|
|
|
|
class CleanupError(RuntimeError):
|
|
"""A torn destination could not be removed; the serial pass would
|
|
trust it, so the build must fail rather than bake a corrupt image."""
|
|
|
|
|
|
class LockReleaseError(RuntimeError):
|
|
"""The manager lock could not be released; the serial pass would
|
|
block on it, so the build must fail with the cause named."""
|
|
|
|
|
|
def parse_specs(path: str, args: argparse.Namespace) -> tuple[list, list, list]:
|
|
"""Extract lib/platform/tool specs from every section of a platformio.ini."""
|
|
config = configparser.ConfigParser(inline_comment_prefixes=(";",))
|
|
if not config.read(path):
|
|
# ConfigParser silently ignores unreadable files; an empty spec
|
|
# list would build an image with no dependencies at all
|
|
raise SystemExit(f"Could not read {path}")
|
|
libs = []
|
|
tools = []
|
|
platforms = []
|
|
for section in config.sections():
|
|
conf = config[section]
|
|
if "lib_deps" in conf and args.libraries:
|
|
for lib_dep in conf["lib_deps"].splitlines():
|
|
if not lib_dep:
|
|
# Empty line or comment
|
|
continue
|
|
if lib_dep.startswith("${"):
|
|
# Extending from another section
|
|
continue
|
|
if "@" not in lib_dep:
|
|
# No version pinned, this is an internal lib
|
|
continue
|
|
libs.append(lib_dep)
|
|
if "platform" in conf and args.platforms:
|
|
platforms.append(conf["platform"])
|
|
if "platform_packages" in conf and args.tools:
|
|
for tool in conf["platform_packages"].splitlines():
|
|
if not tool:
|
|
# Empty line or comment
|
|
continue
|
|
if tool.startswith("${"):
|
|
# Extending from another section
|
|
continue
|
|
if tool.find("https://github.com") != -1:
|
|
split = tool.find("@")
|
|
tool = tool[split + 1 :]
|
|
tools.append(tool)
|
|
# Exact-string dedupe only: name-level dedupe would change which
|
|
# version conflicts the pkg install pass reconciles
|
|
return (
|
|
list(dict.fromkeys(libs)),
|
|
list(dict.fromkeys(platforms)),
|
|
list(dict.fromkeys(tools)),
|
|
)
|
|
|
|
|
|
def piopm_matches(package_dir: str, spec) -> list[Path]:
|
|
"""Dirs whose .piopm metadata names this spec; a positive match beats
|
|
guessing the manifest-derived dirname from the registry name."""
|
|
want = (BasePackageManager.ensure_spec(spec).name or "").lower()
|
|
matches: list[Path] = []
|
|
if not want:
|
|
return matches
|
|
try:
|
|
entries = list(Path(package_dir).iterdir())
|
|
except FileNotFoundError:
|
|
return matches
|
|
for d in entries:
|
|
if not d.is_dir():
|
|
continue # pio's get_installed skips files and *.pio-link too
|
|
try:
|
|
meta = fs.load_json(str(d / ".piopm"))
|
|
except FileNotFoundError:
|
|
continue # no metadata means pio does not trust it either
|
|
except (OSError, ValueError):
|
|
if d.name.lower() == want:
|
|
# A corrupt .piopm under this spec's own name would crash
|
|
# pio's whole storage scan; remove it
|
|
matches.append(d)
|
|
continue
|
|
mspec = meta.get("spec") or {}
|
|
if (mspec.get("name") or meta.get("name") or "").lower() == want:
|
|
matches.append(d)
|
|
return matches
|
|
|
|
|
|
def remove_dir(spec, dest: Path) -> None:
|
|
# fs.rmtree never raises (errors go to a printing onexc handler);
|
|
# only the destination's absence proves the cleanup worked
|
|
fs.rmtree(str(dest))
|
|
if dest.exists():
|
|
# Failing the build beats baking a corrupt image
|
|
raise CleanupError(
|
|
f"could not remove the failed pre-install of {spec} at {dest}"
|
|
)
|
|
print(f"Removed torn destination {dest}", flush=True)
|
|
|
|
|
|
def cleanup_or_die(mgr, spec) -> None:
|
|
"""Cleanup that did not demonstrably succeed must fail the build."""
|
|
try:
|
|
clean_torn(mgr, spec)
|
|
except CleanupError:
|
|
raise
|
|
except Exception as err: # noqa: BLE001
|
|
raise CleanupError(f"cleanup failed for {spec}: {err!r}") from err
|
|
|
|
|
|
def clean_torn(mgr, spec) -> None:
|
|
"""Remove a torn destination so the serial pass cannot trust it."""
|
|
pkg = None
|
|
with suppress(Exception):
|
|
# get_package memoizes a pre-install snapshot; reset to see the
|
|
# torn dir. It also recognizes manifest-only legacy dirs pio's
|
|
# storage scan would trust, which the .piopm fallback cannot see.
|
|
mgr.memcache_reset()
|
|
pkg = mgr.get_package(spec)
|
|
if pkg is not None:
|
|
remove_dir(spec, Path(pkg.path))
|
|
elif dests := piopm_matches(mgr.package_dir, spec):
|
|
# A .piopm naming this spec is the exact shape the serial pass
|
|
# trusts; a dir without one is overwritten by pio's own install
|
|
for dest in dests:
|
|
remove_dir(spec, dest)
|
|
else:
|
|
print(f"No resolvable destination to clean for {spec}", flush=True)
|
|
|
|
|
|
def spec_key(spec) -> str | None:
|
|
"""The destination identity of a spec: PlatformIO installs by package
|
|
name, so two specs sharing a name share a directory. ``None`` means
|
|
the name could not be derived; such a spec must stay out of the wave
|
|
(a raw-string key would break the one-per-destination guarantee)."""
|
|
name = BasePackageManager.ensure_spec(spec).name
|
|
return name.lower() if name else None
|
|
|
|
|
|
def dependency_specs(manager, specs: list) -> list:
|
|
"""``(spec, compatibility)`` registry dependencies of installed
|
|
packages, from local manifest reads. Name-only dependencies
|
|
(platform-bundled libs like SPI) stay with the ``pkg install`` pass;
|
|
the compatibility qualifiers mirror pio's install_dependency, so a
|
|
qualified dep resolves to the same package the serial pass picks."""
|
|
return [
|
|
(manager.dependency_to_spec(dep), PackageCompatibility.from_dependency(dep))
|
|
for spec in specs
|
|
if (pkg := manager.get_package(spec)) is not None
|
|
for dep in manager.get_pkg_dependencies(pkg) or []
|
|
if dep.get("owner") or dep.get("version")
|
|
]
|
|
|
|
|
|
def parallel_install(manager_cls, specs: list, prior_names: set | None = None) -> None:
|
|
"""Best-effort parallel top-level install.
|
|
|
|
PlatformIO's own installer downloads and unpacks one package at a time
|
|
on one core. Dependencies are skipped (two packages sharing one must
|
|
not extract into the same directory from two threads) and failures are
|
|
only reported: the stock ``pkg install`` pass afterwards installs
|
|
whatever is missing and is the authority on the final state.
|
|
"""
|
|
if not specs:
|
|
return
|
|
manager = manager_cls(None)
|
|
# One spec per destination: two threads must not extract into the
|
|
# same directory. Second versions of a name and URL specs (their dir
|
|
# comes from the archive manifest) stay with the pkg install pass.
|
|
seen_names: set = prior_names if prior_names is not None else set()
|
|
# Wave-1 items are strings; dependency waves carry (spec, compatibility)
|
|
pairs = [item if isinstance(item, tuple) else (item, None) for item in specs]
|
|
unique = {}
|
|
for spec, compat in pairs:
|
|
# Normalize once: a dependency's URL version surfaces as spec.uri
|
|
parsed = BasePackageManager.ensure_spec(spec)
|
|
if parsed.uri:
|
|
continue
|
|
if (key := spec_key(parsed)) is None:
|
|
# No name, no destination identity; leave it to the serial pass
|
|
print(f"Skipping unresolvable spec {spec!r} in the wave", flush=True)
|
|
continue
|
|
unique.setdefault(key, (spec, compat)) # first-wins, like pio's walk
|
|
pending = [
|
|
(spec, compat)
|
|
for spec, compat in unique.values()
|
|
if not manager.get_package(spec)
|
|
]
|
|
if not pending:
|
|
# Nothing to install, but a warm store's dependencies must still
|
|
# feed the next wave (a transitive dep may be missing)
|
|
_next_wave(manager_cls, manager, unique, seen_names)
|
|
return
|
|
workers = min(len(pending), MAX_WORKERS)
|
|
# One manager per worker (_install mutates instance state); built
|
|
# serially because construction rewires the shared manager logger
|
|
managers: queue.SimpleQueue = queue.SimpleQueue()
|
|
for _ in range(workers):
|
|
managers.put(manager_cls(None))
|
|
local = threading.local()
|
|
|
|
def install_one(item) -> bool:
|
|
spec, compat = item
|
|
if (mgr := getattr(local, "mgr", None)) is None:
|
|
mgr = local.mgr = managers.get_nowait()
|
|
try:
|
|
mgr._install( # noqa: SLF001
|
|
spec, skip_dependencies=True, compatibility=compat
|
|
)
|
|
return True
|
|
except Exception as err: # noqa: BLE001
|
|
print(f"Pre-install of {spec} failed ({err!r})", flush=True)
|
|
cleanup_or_die(mgr, spec)
|
|
return False
|
|
except BaseException:
|
|
# A worker SystemExit (main() guards against it) must not skip
|
|
# the cleanup and leave a torn dir the serial pass trusts
|
|
cleanup_or_die(mgr, spec)
|
|
raise
|
|
|
|
print(f"Preinstalling {len(pending)} package(s) with {workers} workers", flush=True)
|
|
# The serial getter calls create pio's lazy dirs (made without
|
|
# exist_ok) before cold-cache workers can race the creation
|
|
manager.get_download_dir()
|
|
manager.get_tmp_dir()
|
|
ContentCache("http")
|
|
cwd = Path.cwd()
|
|
manager.lock()
|
|
try:
|
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
futures = [ex.submit(install_one, item) for item in pending]
|
|
# The with-block joined every future; drain them all so a
|
|
# concurrent CleanupError is never dropped
|
|
errors = [err for f in futures if (err := f.exception()) is not None]
|
|
for err in errors:
|
|
# Every failure is on the record; the raised one is a summary
|
|
print(f"Wave failure: {err!r}", flush=True)
|
|
if errors:
|
|
raise next((e for e in errors if isinstance(e, CleanupError)), errors[0])
|
|
results = [f.result() for f in futures]
|
|
finally:
|
|
try:
|
|
manager.unlock()
|
|
except Exception as unlock_err: # noqa: BLE001
|
|
# A held flock would hang the serial pass in another process;
|
|
# failing loudly beats an unexplained stuck docker build. Any
|
|
# in-flight error stays attached as the context.
|
|
raise LockReleaseError(
|
|
f"could not release the manager lock: {unlock_err!r}"
|
|
) from unlock_err
|
|
# Worker postinstall scripts chdir process-wide (pio's fs.cd);
|
|
# restore between waves. The serial pass pins its own cwd.
|
|
with suppress(OSError):
|
|
os.chdir(cwd)
|
|
if failures := len(results) - sum(results):
|
|
# The stock pass retries CLI specs and re-walks installed
|
|
# packages' dependencies, so failed deps retry too
|
|
print(
|
|
f"Pre-install failed for {failures} of {len(results)} package(s); "
|
|
"pkg install retries them serially",
|
|
flush=True,
|
|
)
|
|
|
|
# Waves skip dependencies (a shared one must not extract from two
|
|
# threads); the installed manifests feed the next wave
|
|
_next_wave(manager_cls, manager, unique, seen_names)
|
|
|
|
|
|
def _next_wave(manager_cls, manager, unique: dict, seen_names: set) -> None:
|
|
"""Queue the dependency wave for every requested spec, installed or
|
|
freshly waved; a warm store can still be missing a transitive dep.
|
|
Terminates without a cap: each wave admits only never-seen names."""
|
|
seen_names.update(unique)
|
|
# The pre-wave get_package calls memoized an empty storage snapshot
|
|
manager.memcache_reset()
|
|
next_specs = [
|
|
item
|
|
for item in dependency_specs(manager, [spec for spec, _ in unique.values()])
|
|
if spec_key(item[0]) not in seen_names
|
|
]
|
|
if next_specs:
|
|
parallel_install(manager_cls, next_specs, seen_names)
|
|
|
|
|
|
def build_cli_args(libs: list, platforms: list, tools: list) -> list:
|
|
return [
|
|
arg
|
|
for flag, specs in (("-l", libs), ("-p", platforms), ("-t", tools))
|
|
for spec in specs
|
|
for arg in (flag, spec)
|
|
]
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="")
|
|
parser.add_argument("file", help="Path to platformio.ini", nargs=1)
|
|
parser.add_argument(
|
|
"-l", "--libraries", help="Install libraries", action="store_true"
|
|
)
|
|
parser.add_argument(
|
|
"-p", "--platforms", help="Install platforms", action="store_true"
|
|
)
|
|
parser.add_argument("-t", "--tools", help="Install tools", action="store_true")
|
|
args = parser.parse_args()
|
|
start_cwd = Path.cwd()
|
|
libs, platforms, tools = parse_specs(args.file[0], args)
|
|
|
|
# Platforms stay serial: PlatformPackageManager.install runs an
|
|
# on_installed hook the private _install path would skip
|
|
if PARALLEL_AVAILABLE:
|
|
wave_groups = [(ToolPackageManager, tools), (LibraryPackageManager, libs)]
|
|
else: # pragma: no cover
|
|
wave_groups = []
|
|
print(
|
|
f"PlatformIO layout changed ({IMPORT_ERROR}); serial install only",
|
|
flush=True,
|
|
)
|
|
for manager_cls, specs in wave_groups:
|
|
try:
|
|
parallel_install(manager_cls, specs)
|
|
except (CleanupError, LockReleaseError, KeyboardInterrupt):
|
|
# A torn package or a held lock must fail the build
|
|
raise
|
|
except BaseException: # noqa: BLE001
|
|
# BaseException: a worker postinstall's SystemExit must not
|
|
# skip the authoritative serial pass (partial deps, exit 0)
|
|
print("Parallel preinstall failed, falling back to serial", flush=True)
|
|
traceback.print_exc()
|
|
|
|
# Postinstall scripts chdir process-wide (pio's fs.cd captures its
|
|
# restore path at construction); pin the authoritative pass's cwd
|
|
subprocess.check_call(
|
|
["platformio", "pkg", "install", "-g", *build_cli_args(libs, platforms, tools)],
|
|
close_fds=False,
|
|
cwd=start_cwd,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|