diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 6c472d3455..fc163964c8 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -739,6 +739,15 @@ def normalize_dependencies( manifest_name, ) continue + if "version" in entry and not isinstance(entry["version"], str): + # A container would raise from set.add(); an int fails + # opaquely inside the registry resolution + _LOGGER.warning( + "Ignoring unrecognized dependency entry %r of %s", + entry, + manifest_name, + ) + continue normalized.append(entry) return normalized if not isinstance(dependencies, (list, tuple)): @@ -761,6 +770,15 @@ def normalize_dependencies( manifest_name, ) continue + if "version" in entry and not isinstance(entry["version"], str): + # A container would raise from set.add(); an int fails + # opaquely inside the registry resolution + _LOGGER.warning( + "Ignoring unrecognized dependency entry %r of %s", + entry, + manifest_name, + ) + continue normalized.append(entry) elif isinstance(entry, str) and entry: # PIO also accepts a bare list of names ("dependencies": ["Wire"]) @@ -948,8 +966,12 @@ def _content_lengths(urls: list[str]) -> list[int]: def head(url: str) -> int: try: resp = requests.head(url, timeout=10, allow_redirects=True) + if not resp.ok: + _LOGGER.debug("HEAD %s returned %s", url, resp.status_code) + return 0 return int(resp.headers.get("content-length", 0)) - except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught + except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught + _LOGGER.debug("HEAD %s failed: %s", url, err) return 0 with ThreadPoolExecutor(max_workers=min(_DOWNLOAD_WORKERS, len(urls))) as ex: @@ -1000,16 +1022,19 @@ def _prefetch_wave( tracker = progress.tracker() try: component.download(salt=salt, namespace=namespace, progress=tracker) - except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught + except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught # The sequential call below retries and reports the failure + _LOGGER.debug("Prefetch of %s failed: %s", component.name, err) tracker(0) + ex = ThreadPoolExecutor(max_workers=min(_DOWNLOAD_WORKERS, len(components))) try: - with ThreadPoolExecutor( - max_workers=min(_DOWNLOAD_WORKERS, len(components)) - ) as ex: - list(ex.map(_fetch, components)) + for future in [ex.submit(_fetch, component) for component in components]: + 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() diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 6a578b4979..e36a7fa353 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -631,7 +631,7 @@ def test_lex_build_flags_dangling_flag_does_not_cross_entries( def test_prefetch_wave_downloads_registry_archives_in_parallel( - monkeypatch: pytest.MonkeyPatch, + setup_core, monkeypatch: pytest.MonkeyPatch ) -> None: """Registry archives in one wave download concurrently, deduped by URL; git/local sources and failures are left to the sequential call.""" @@ -670,12 +670,18 @@ def test_content_lengths_head_requests(monkeypatch: pytest.MonkeyPatch) -> None: def fake_head(url, timeout, allow_redirects): if "bad" in url: raise requests.ConnectionError("down") - return SimpleNamespace(headers={"content-length": "123"}) + if "gone" in url: + return SimpleNamespace(ok=False, status_code=404, headers={}) + return SimpleNamespace(ok=True, 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] + assert lib._content_lengths(["https://x/a", "https://x/bad", "https://x/gone"]) == [ + 123, + 0, + 0, + ] def test_prefetch_wave_cache_probe_failure_still_prefetches( @@ -766,6 +772,11 @@ def test_normalize_dependencies_forms(caplog) -> None: {"": "1.0", "Wire": {"name": 123, "version": "1.0"}, "SPI": "*"}, "libx" ) == [{"name": "SPI", "owner": None, "version": "*"}] assert caplog.text.count("unrecognized dependency entry") == 5 + # A container or numeric version would raise from set.add() or fail + # opaquely in the registry; both spellings warn and drop + assert normalize_dependencies({"Foo": ["1.0", "2.0"]}, "libx") == [] + assert normalize_dependencies([{"name": "Foo", "version": 1}], "libx") == [] + assert caplog.text.count("unrecognized dependency entry") == 7 @pytest.mark.parametrize(