Compare commits

...
15 Commits
Author SHA1 Message Date
J. Nick Koston 1e8f084741 [ci] Stop saving an empty apt cache when the install fails (#21713) 2026-10-27 19:34:58 +13:00
Jesse Hills 6595df0cab Bump version to 2026.10.1 2026-10-27 16:17:54 +13:00
J. Nick Koston 73894b2490 [core] Turn off the precompiled header with ccache older than 4.7 (#21627) 2026-10-27 16:17:53 +13:00
Jonathan Swoboda 9bef20d8dc [core] Avoid division by zero in Color::random_color() (#21623) 2026-10-27 16:17:53 +13:00
Jonathan Swoboda 0e440500a2 [core] Generate valid C++ for infinite float values (#21625) 2026-10-27 16:17:53 +13:00
minimicro34 badfa84fe0 [esp32_ble] Ignore unhandled BLE key GAP events (#21457) 2026-10-27 16:17:53 +13:00
J. Nick Koston e686ecf76e [core] Resolve version-less library dependencies like PlatformIO (#21317) 2026-10-27 16:17:53 +13:00
Jonathan Swoboda 64fb4e06b5 [ledc] Reset the peripheral through the HAL on targets without the legacy periph API (#21397) 2026-10-27 16:17:53 +13:00
Jonathan Swoboda cb82c9031a [core] Keep the color codes of a filtered build output line (#21396) 2026-10-27 16:17:53 +13:00
J. Nick KostonandJonathan Swoboda ca74ab9873 [climate] Add StringRef overloads for ClimateCall::set_fan_mode and set_preset (#21359)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-10-27 16:17:53 +13:00
J. Nick Koston 86fc740b65 [http_request] Copy a manifest URL set at run time and keep the config literal by pointer (#21373) 2026-10-27 16:17:53 +13:00
J. Nick Koston 450bb52f65 [mqtt] Resend the fan state when its first publish fails (#21328) 2026-10-27 16:17:35 +13:00
J. Nick Koston ed3615ec69 [light] Add a StringRef overload for LightCall::set_effect (#21358) 2026-10-27 16:15:01 +13:00
J. Nick Koston a5f25ebe2d [core] Emit generated lambda code after the user includes (#21307) 2026-10-27 16:12:58 +13:00
Jonathan Swoboda 7a4775de3d [esp32_hosted] Keep the OTA watchdog timeout through the coprocessor end call (#21300) 2026-10-27 15:48:16 +13:00
52 changed files with 1892 additions and 377 deletions

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
name: Install apt packages (cached)
description: >-
Installs libsdl2-dev, libmbedtls-dev and ccache from one shared cache. The cache key comes
only from the package list and version, so both live here and nowhere else.
runs:
using: "composite"
steps:
- name: Keep a failed install out of the cache
# cache-apt-pkgs ignores install failures and saved an empty cache when a stale package
# list got a 404 (upstream awalsh128/cache-apt-pkgs-action#221). It uses an apt-fast
# already on PATH: this one refreshes the lists and retries when the install fails and,
# if that fails too, removes the folder the action would save. Only a cache miss installs.
shell: bash
run: |
sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
Acquire::Retries "1";
Acquire::http::Timeout "15";
Acquire::https::Timeout "15";
EOF
sudo tee /usr/local/bin/apt-fast > /dev/null <<'EOF'
#!/bin/bash
if [[ " $* " != *" install "* ]]; then
exec apt-get "$@"
fi
touch /tmp/apt-fast-standin-ran
apt-get "$@" && exit 0
apt-get update -qq >&2
# The action pins each package to the version the stale lists named
args=()
for arg in "$@"; do
if [[ "${arg}" == -* ]]; then args+=("${arg}"); else args+=("${arg%%=*}"); fi
done
apt-get "${args[@]}" && exit 0
# The action writes our output to install.log inside the folder it saves
log="$(readlink -f "/proc/$$/fd/1")"
if [ "$(basename "${log}")" = install.log ]; then
rm -rf "$(dirname "${log}")"
else
touch /tmp/apt-fast-cleanup-missed
fi
exit 1
EOF
sudo chmod +x /usr/local/bin/apt-fast
- name: Install apt packages (cached)
id: apt
# Pinned on purpose and not watched by Dependabot: the step above relies on how this
# release calls apt-fast and where it writes install.log.
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev libmbedtls-dev ccache
# 1.3 drops the empty caches saved before the step above existed
version: 1.3
- name: Check the packages are installed
shell: bash
env:
CACHE_HIT: ${{ steps.apt.outputs.cache-hit }}
run: |
if [ "${CACHE_HIT}" != "true" ] && [ ! -e /tmp/apt-fast-standin-ran ]; then
echo "::error::cache-apt-pkgs did not use the apt-fast stand-in; check the pinned release"
exit 1
fi
if [ -e /tmp/apt-fast-cleanup-missed ]; then
echo "::error::the failed install may have been cached; check the pinned release and bump the version"
exit 1
fi
# The action restores files without registering them with dpkg
for cmd in ccache sdl2-config; do
if ! command -v "${cmd}" > /dev/null; then
echo "::error::${cmd} is missing after the cached install"
exit 1
fi
done
if [ ! -f /usr/include/mbedtls/version.h ]; then
echo "::error::libmbedtls-dev is missing after the cached install"
exit 1
fi
+22 -23
View File
@@ -73,16 +73,17 @@ jobs:
runs-on: ubuntu-24.04
# PR-branch cache saves are invisible to other PRs, so dev/beta/release
# pushes seed the one shared entry PR jobs restore. The key is derived
# only from the package list and version; keep both identical in every
# step that restores it. In ci-status needs so a broken seed fails dev.
# only from the package list and version, kept in the apt-packages action.
# In ci-status needs so a broken seed fails dev.
if: github.event_name == 'push'
timeout-minutes: 10
steps:
- name: Install apt packages (cached)
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
packages: libsdl2-dev ccache
version: 1.1
sparse-checkout: .github
- name: Install apt packages (cached)
uses: ./.github/actions/apt-packages
determine-jobs:
name: Determine which jobs to run
@@ -409,14 +410,10 @@ jobs:
- name: Install apt packages (cached)
# ccache speeds up the host compiles. A cache hit never touches apt
# (mirror outages cannot hang the job); the timeout bounds the cold
# path. Packages and version must match seed-apt-cache exactly.
# libsdl2-dev is needed by the headless display tests, which capture
# screenshots.
# path. libsdl2-dev is needed by the headless display tests, which
# capture screenshots.
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev ccache
version: 1.1
uses: ./.github/actions/apt-packages
- name: Set up Python 3.13
id: python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
@@ -707,6 +704,12 @@ jobs:
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
- name: Install apt packages (cached)
if: matrix.pio_cache_key == 'tidyhost'
# libsdl2-dev is needed by the sdl component on the host, libmbedtls-dev
# by dsmr and dlms_meter.
timeout-minutes: 10
uses: ./.github/actions/apt-packages
- name: Restore Python
id: restore-python
@@ -1093,20 +1096,16 @@ jobs:
- name: List components
run: echo ${{ matrix.batch.components }}
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install apt packages (cached)
# A cache hit (seeded on dev by seed-apt-cache) never touches apt,
# so mirror outages cannot hang this PR-only job; the timeout bounds
# the cold path. Packages and version must match seed-apt-cache
# exactly. The action has no --no-install-recommends; same package
# set this job used before #17463.
# the cold path. The action has no --no-install-recommends; same
# package set this job used before #17463.
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev ccache
version: 1.1
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: ./.github/actions/apt-packages
- name: Restore Python
uses: ./.github/actions/restore-python
with:
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.10.0
PROJECT_NUMBER = 2026.10.1
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
-2
View File
@@ -44,7 +44,6 @@ from esphome.platformio.library import (
normalize_dependencies,
parse_library_json,
parse_library_properties,
warn_properties_depends,
)
_LOGGER = logging.getLogger(__name__)
@@ -283,7 +282,6 @@ def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary:
"resolved automatically; add them with add_library() if needed",
name,
)
warn_properties_depends(name, data)
build = data.get("build")
if isinstance(build, dict) and build.get("extraScript"):
# Scripts only run on the converted path; building without
+30 -13
View File
@@ -7,6 +7,7 @@ import re
import textwrap
from esphome.build_helpers import pch
from esphome.build_helpers.ccache import CCACHE_PCH_MIN_VERSION
from esphome.components.esp32 import (
get_esp32_variant,
get_excluded_builtin_components,
@@ -474,7 +475,7 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC
# Where CMake puts the .gch of the src component; ccache reads the checksum
# next to it in place of the .gch
_PCH_SUM_PATH = "build/esp-idf/src/CMakeFiles/__idf_src.dir/cmake_pch.hxx.gch.sum"
# Where the Windows gate records its choice
# Where the CMake gate records its choice
_PCH_CHOICE_VAR = "ESPHOME_PCH"
@@ -490,16 +491,32 @@ def _pch_cmake_block() -> str:
block = f"""target_precompile_headers(${{COMPONENT_LIB}} PRIVATE
{headers}
)"""
if not pch.pch_needs_gcc_check():
if pch.pch_forced():
return f"\n# ESPHome precompiled header\n{block}\n"
# Before the first configure only CMake knows the compiler version
return f"""
# ESPHome precompiled header, unless GCC bug 14940 keeps it from loading
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ({pch.PCH_WINDOWS_CMAKE_OLD_GCC}))
# Only CMake knows the compiler version before the first configure, and
# the ccache IDF found is the one the build runs
gcc_gate = ""
if pch.pch_needs_gcc_check():
gcc_gate = f"""if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ({pch.PCH_WINDOWS_CMAKE_OLD_GCC}))
message(STATUS "ESPHome: GCC ${{CMAKE_CXX_COMPILER_VERSION}} cannot load a precompiled header on Windows; compiling without it")
set({_PCH_CHOICE_VAR} OFF CACHE BOOL "ESPHome precompiled header in use" FORCE)
else()
set({_PCH_CHOICE_VAR} ON CACHE BOOL "ESPHome precompiled header in use" FORCE)
set(_esphome_pch OFF)
endif()
"""
min_ccache = ".".join(map(str, CCACHE_PCH_MIN_VERSION))
return f"""
# ESPHome precompiled header, unless a check below rules it out
set(_esphome_pch ON)
{gcc_gate}if(CCACHE_ENABLE AND CCACHE_FOUND)
execute_process(COMMAND "${{CCACHE_FOUND}}" --version OUTPUT_VARIABLE _esphome_ccache ERROR_QUIET)
if(_esphome_ccache MATCHES "ccache version ([0-9.]+)")
if(CMAKE_MATCH_1 VERSION_LESS {min_ccache})
message(STATUS "ESPHome: ccache ${{CMAKE_MATCH_1}} can reuse stale objects with a precompiled header; compiling without it (upgrade ccache to {min_ccache} or newer, or set ESPHOME_PCH_ENABLE=1 to force)")
set(_esphome_pch OFF)
endif()
endif()
endif()
set({_PCH_CHOICE_VAR} ${{_esphome_pch}} CACHE BOOL "ESPHome precompiled header in use" FORCE)
if(_esphome_pch)
{textwrap.indent(block, " ")}
endif()
"""
@@ -516,10 +533,10 @@ def write_pch_checksum() -> None:
if not pch.pch_enabled():
return
# The gate's choice, cached by configure
if pch.pch_needs_gcc_check() and get_cmake_cache_value(_PCH_CHOICE_VAR) != "ON":
return
pch.log_pch_in_use()
# Written whatever the gate chose last time: a build tree without that
# choice, or a reconfigure that turns the pch on, must not see a stale sum
if pch.pch_forced() or get_cmake_cache_value(_PCH_CHOICE_VAR) == "ON":
pch.log_pch_in_use()
checksum = pch.pch_checksum(
CORE.relative_src_path(),
pch.PCH_DEFAULT_HEADERS,
+48 -1
View File
@@ -3,21 +3,68 @@ resolution, and default ``CCACHE_*`` values."""
from __future__ import annotations
import functools
import logging
import os
from pathlib import Path
import re
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
from esphome.framework_helpers import (
strip_win_long_path_prefix,
tool_version_probe,
tool_version_runs,
)
from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS
_LOGGER = logging.getLogger(__name__)
# Before 4.7 ccache stores a pch consumer whose header is newer than one
# second without that header, so a later build reuses it after the header
# changed (#21621)
CCACHE_PCH_MIN_VERSION = (4, 7)
_VERSION_RE = re.compile(r"ccache version (\d+(?:\.\d+)*)")
@functools.cache
def _ccache_probe(ccache: str) -> str | Exception:
"""``ccache --version``, run once per binary per process."""
return tool_version_probe(ccache)
def _ccache_runs(ccache: str) -> bool:
"""Return True when the ``ccache`` found on PATH actually runs."""
return tool_version_runs(
ccache,
"Ignoring ccache at %s because it failed to run; compiling without ccache",
probe=_ccache_probe,
)
@functools.cache
def _ccache_too_old_for_pch(ccache: str) -> bool:
output = _ccache_probe(ccache)
if not isinstance(output, str) or (found := _VERSION_RE.search(output)) is None:
return False
if tuple(map(int, found[1].split("."))) >= CCACHE_PCH_MIN_VERSION:
return False
_LOGGER.info(
"ccache %s at %s can reuse stale objects with a precompiled header; "
"compiling without it (upgrade ccache to %s or newer, or set "
"ESPHOME_PCH_ENABLE=1 to force)",
found[1],
ccache,
".".join(map(str, CCACHE_PCH_MIN_VERSION)),
)
return True
def ccache_breaks_pch() -> bool:
"""Whether the ccache this build uses is too old for a precompiled header.
ESP-IDF builds check the ccache IDF finds in CMake instead (build_gen.espidf)."""
return (ccache := resolve_ccache_path()) is not None and _ccache_too_old_for_pch(
ccache
)
+19 -4
View File
@@ -12,7 +12,11 @@ import re
import subprocess
import sys
from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env
from esphome.build_helpers.ccache import (
ccache_breaks_pch,
effective_ccache_basedir,
parse_enable_env,
)
from esphome.const import PLATFORM_NRF52
from esphome.helpers import write_file_if_changed
@@ -117,9 +121,16 @@ def pch_needs_gcc_check() -> bool:
return sys.platform == "win32" and parse_enable_env("ESPHOME_PCH_ENABLE") is None
def _ccache_rules_out_pch() -> bool:
"""An old ccache, unless ``ESPHOME_PCH_ENABLE=1`` forces the pch on.
ESP-IDF runs the same check in CMake (build_gen.espidf)."""
return not pch_forced() and ccache_breaks_pch()
def pch_usable(cxx: Sequence[Path | str]) -> bool:
"""The knob plus the host rule; ``ESPHOME_PCH_ENABLE=1`` skips the rule."""
if not pch_enabled():
"""The knob plus the ccache and host rules; ``ESPHOME_PCH_ENABLE=1``
skips the rules."""
if not pch_enabled() or _ccache_rules_out_pch():
return False
if not pch_needs_gcc_check():
return True
@@ -162,7 +173,11 @@ def pch_script_enabled() -> bool:
"""Whether this PlatformIO build takes the pch script."""
from esphome.core import CORE
return pch_enabled() and CORE.target_platform not in PCH_SCRIPT_EXCLUDED_PLATFORMS
return (
pch_enabled()
and CORE.target_platform not in PCH_SCRIPT_EXCLUDED_PLATFORMS
and not _ccache_rules_out_pch()
)
def pch_header_text(include_headers: Iterable[str]) -> str:
+3 -1
View File
@@ -111,7 +111,9 @@ class ToolOutput(RedirectText):
def _emit_line(self, line: str) -> None:
if self._progress and _PROGRESS.match(line):
if not self._is_filtered(line):
if self._is_filtered(line):
self._drop_line(line)
else:
text = _fit_terminal(line.strip("\r\n"))
self._write_color_replace(f"\r{text}\x1b[K")
self._on_progress_line = True
+8
View File
@@ -82,6 +82,10 @@ class ClimateCall {
ClimateCall &set_fan_mode(optional<std::string> fan_mode);
/// Set the custom fan mode of the climate device.
ClimateCall &set_fan_mode(const char *custom_fan_mode);
/// Set the custom fan mode of the climate device (StringRef overload to resolve ambiguity, zero-copy).
ClimateCall &set_fan_mode(StringRef custom_fan_mode) {
return this->set_fan_mode(custom_fan_mode.c_str(), custom_fan_mode.size());
}
/// Set the custom fan mode of the climate device (zero-copy API path).
ClimateCall &set_fan_mode(const char *custom_fan_mode, size_t len);
/// Set the swing mode of the climate device.
@@ -102,6 +106,10 @@ class ClimateCall {
ClimateCall &set_preset(optional<std::string> preset);
/// Set the custom preset of the climate device.
ClimateCall &set_preset(const char *custom_preset);
/// Set the custom preset of the climate device (StringRef overload to resolve ambiguity, zero-copy).
ClimateCall &set_preset(StringRef custom_preset) {
return this->set_preset(custom_preset.c_str(), custom_preset.size());
}
/// Set the custom preset of the climate device (zero-copy API path).
ClimateCall &set_preset(const char *custom_preset, size_t len);
+19 -1
View File
@@ -3683,6 +3683,20 @@ def _write_sdkconfig():
clean_build(clear_pio_cache=False)
def _arduino_provides(name: str) -> bool:
"""Whether arduino-esp32 bundles the library a manifest depends on, so it is
never fetched by name; it still has to be enabled to build."""
if name not in ARDUINO_DISABLED_LIBRARIES:
return False
if name not in CORE.data[KEY_ESP32][KEY_ARDUINO_LIBRARIES]:
_LOGGER.warning(
"A library depends on the Arduino library %s, which is not enabled; "
"add it under esphome: libraries:",
name,
)
return True
def _write_idf_component_yml():
yml_path = CORE.relative_build_path("src/idf_component.yml")
dependencies: dict[str, dict] = {}
@@ -3704,7 +3718,11 @@ def _write_idf_component_yml():
# IDF sees the same requirement twice; converted components reach it through
# ${ESPHOME_PROJECT_MANAGED_COMPONENTS}.
managed = set(CORE.data[KEY_ESP32].get(KEY_COMPONENTS, {}))
converted = generate_idf_components(libraries, managed=managed)
converted = generate_idf_components(
libraries,
managed=managed,
provides=_arduino_provides if CORE.using_arduino else None,
)
# IDF names a component after its directory and a later registration of the
# same name replaces the earlier one, so a stub beside a converted library of
# the same name (espressif/libsodium vs esphome/libsodium) would win or lose
+1
View File
@@ -719,6 +719,7 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa
}
// Ignore these GAP events as they are not relevant for our use case
case ESP_GAP_BLE_KEY_EVT:
case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT:
case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete
case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm
@@ -26,9 +26,14 @@ static const char *const TAG = "esp32_hosted.update";
// Older coprocessor firmware versions have a 1500-byte limit per RPC call
constexpr size_t CHUNK_SIZE = 1500;
// OTA begin blocks while the coprocessor erases its partition
// OTA begin blocks while the coprocessor erases its partition, end while it validates the image
constexpr uint32_t OTA_WDT_TIMEOUT_MS = 60000;
static esp_err_t ota_end_guarded() {
watchdog::WatchdogManager wdt(OTA_WDT_TIMEOUT_MS);
return esp_hosted_slave_ota_end(); // NOLINT
}
#ifdef USE_ESP32_HOSTED_HTTP_UPDATE
// Interval/timeout IDs (uint32_t to avoid string comparison)
constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0;
@@ -380,7 +385,7 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() {
} else {
ESP_LOGE(TAG, "Error reading firmware data: %d", read_or_error);
}
esp_hosted_slave_ota_end(); // NOLINT
ota_end_guarded();
container->end();
this->status_set_error(LOG_STR("Download failed"));
return false;
@@ -390,7 +395,7 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() {
err = esp_hosted_slave_ota_write(buffer, read_or_error); // NOLINT
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err));
esp_hosted_slave_ota_end(); // NOLINT
ota_end_guarded();
container->end();
this->status_set_error(LOG_STR("Failed to write OTA data"));
return false;
@@ -402,7 +407,7 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() {
hasher.calculate();
if (!hasher.equals_bytes(this->firmware_sha256_.data())) {
ESP_LOGE(TAG, "SHA256 mismatch");
esp_hosted_slave_ota_end(); // NOLINT
ota_end_guarded();
this->status_set_error(LOG_STR("SHA256 verification failed"));
return false;
}
@@ -451,7 +456,7 @@ bool Esp32HostedUpdate::write_embedded_firmware_to_coprocessor_() {
err = esp_hosted_slave_ota_write(chunk, chunk_size); // NOLINT
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err));
esp_hosted_slave_ota_end(); // NOLINT
ota_end_guarded();
this->status_set_error(LOG_STR("Failed to write OTA data"));
return false;
}
@@ -494,7 +499,7 @@ void Esp32HostedUpdate::perform(bool force) {
}
// End OTA and activate new firmware
esp_err_t end_err = esp_hosted_slave_ota_end(); // NOLINT
esp_err_t end_err = ota_end_guarded();
if (end_err != ESP_OK) {
ESP_LOGE(TAG, "Failed to end OTA: %s", esp_err_to_name(end_err));
this->state_ = prev_state;
@@ -37,7 +37,7 @@ async def to_code(config: ConfigType) -> None:
request_parent = await cg.get_variable(config[CONF_HTTP_REQUEST_ID])
cg.add(var.set_request_parent(request_parent))
cg.add(var.set_source_url(config[CONF_SOURCE]))
cg.add(var.set_source_url_static(config[CONF_SOURCE]))
ota.request_ota_state_listeners()
@@ -24,12 +24,6 @@ namespace esphome::http_request {
static const char *const TAG = "http_request.update";
// Wraps UpdateInfo + error for the task→main-loop handoff.
struct TaskResult {
update::UpdateInfo info;
const LogString *error_str{nullptr};
};
static const size_t MAX_READ_SIZE = 256;
static constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0;
static constexpr uint32_t INITIAL_CHECK_INTERVAL_MS = 10000;
@@ -68,6 +62,37 @@ void HttpRequestUpdate::on_ota_state(ota::OTAState state, float progress, uint8_
}
}
void HttpRequestUpdate::set_source_url(const char *source_url, size_t len) {
// Copy before releasing, so a caller passing the current URL back in stays valid.
char *copy = new char[len + 1];
memcpy(copy, source_url, len);
copy[len] = '\0';
this->release_source_url_();
this->source_url_ = copy;
this->source_url_owned_ = true;
}
void HttpRequestUpdate::release_source_url_() {
if (this->source_url_owned_)
delete[] this->source_url_;
this->source_url_owned_ = false;
}
void HttpRequestUpdate::begin_check_(TaskResult *result) {
result->update = this;
result->source_url = this->source_url_;
result->owns_source_url = this->source_url_owned_;
this->source_url_owned_ = false;
}
void HttpRequestUpdate::end_check_(TaskResult *result) {
if (this->source_url_ == result->source_url) {
this->source_url_owned_ = result->owns_source_url;
} else if (result->owns_source_url) {
delete[] result->source_url; // replaced while the check ran
}
}
void HttpRequestUpdate::update() {
if (!network::is_connected()) {
ESP_LOGD(TAG, "Network not connected, skipping update check");
@@ -75,28 +100,37 @@ void HttpRequestUpdate::update() {
}
this->cancel_interval(INITIAL_CHECK_INTERVAL_ID);
#ifdef USE_ESP32
if (this->update_task_handle_ != nullptr) {
if (this->task_result_ != nullptr) {
ESP_LOGW(TAG, "Update check already in progress");
return;
}
xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, (void *) this, 1, &this->update_task_handle_);
#endif
// Allocated once per check; every path in the task returns via its single defer, which frees it.
auto *result = new TaskResult();
this->begin_check_(result);
#ifdef USE_ESP32
this->task_result_ = result;
if (xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, result, 1, nullptr) != pdPASS) {
this->task_result_ = nullptr;
this->end_check_(result);
delete result;
}
#else
this->update_task(this);
this->update_task(result);
this->end_check_(result);
#endif
}
void HttpRequestUpdate::update_task(void *params) {
HttpRequestUpdate *this_update = (HttpRequestUpdate *) params;
// Allocate once — every path below returns via the single defer at the end.
// On failure, error_str is set; on success it is nullptr.
auto *result = new TaskResult();
auto *result = static_cast<TaskResult *>(params);
HttpRequestUpdate *this_update = result->update;
auto *info = &result->info;
const char *source_url = result->source_url;
auto container = this_update->request_parent_->get(this_update->source_url_);
auto container = this_update->request_parent_->get(source_url);
if (container == nullptr || container->status_code != HTTP_STATUS_OK) {
ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_);
ESP_LOGE(TAG, "Failed to fetch manifest from %s", source_url);
if (container != nullptr)
container->end();
result->error_str = LOG_STR("Failed to fetch manifest");
@@ -176,14 +210,14 @@ void HttpRequestUpdate::update_task(void *params) {
allocator.deallocate(data, content_length);
if (!valid) {
ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_);
ESP_LOGE(TAG, "Failed to parse JSON from %s", source_url);
result->error_str = LOG_STR("Failed to parse manifest JSON");
goto defer; // NOLINT(cppcoreguidelines-avoid-goto)
}
// Merge source_url_ and firmware_url
if (!info->firmware_url.empty() && info->firmware_url.find("http") == std::string::npos) {
const char *source = this_update->source_url_;
const char *source = source_url;
const size_t source_len = strlen(source);
size_t prefix_len;
if (info->firmware_url[0] == '/') {
@@ -215,7 +249,8 @@ defer:
// Lambda captures only 2 pointers (8 bytes) — fits in std::function SBO on supported toolchains.
this_update->defer([this_update, result]() {
#ifdef USE_ESP32
this_update->update_task_handle_ = nullptr;
this_update->task_result_ = nullptr;
this_update->end_check_(result);
#endif
if (result->error_str != nullptr) {
this_update->status_set_error(result->error_str);
@@ -13,6 +13,21 @@
namespace esphome::http_request {
namespace testing {
class HttpRequestUpdateTest;
} // namespace testing
class HttpRequestUpdate;
// One manifest check: handed from update() to the task and back to the main loop.
struct TaskResult {
HttpRequestUpdate *update{nullptr};
const char *source_url{nullptr}; // the URL this check reads, fixed before it starts
update::UpdateInfo info;
const LogString *error_str{nullptr}; // set on failure, nullptr on success
bool owns_source_url{false}; // the check holds the buffer until it ends, see begin_check_()
};
class HttpRequestUpdate final : public update::UpdateEntity, public PollingComponent, public ota::OTAStateListener {
public:
void setup() override;
@@ -21,7 +36,18 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo
void perform(bool force) override;
void check() override { this->update(); }
void set_source_url(const char *source_url) { this->source_url_ = source_url; }
~HttpRequestUpdate() override { this->release_source_url_(); }
/// Codegen only: the config literal lives in flash for the life of the program, so it is kept by pointer.
void set_source_url_static(const char *source_url) {
this->release_source_url_();
this->source_url_ = source_url;
}
/// Set the manifest URL at run time; the string is copied, so any storage may be passed.
void set_source_url(const char *source_url) { this->set_source_url(source_url, strlen(source_url)); }
void set_source_url(const std::string &source_url) { this->set_source_url(source_url.data(), source_url.size()); }
void set_source_url(const char *source_url, size_t len);
const char *get_source_url() const { return this->source_url_; }
void set_request_parent(HttpRequestComponent *request_parent) { this->request_parent_ = request_parent; }
void set_ota_parent(OtaHttpRequestComponent *ota_parent) { this->ota_parent_ = ota_parent; }
@@ -36,12 +62,21 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo
static void update_task(void *params);
#ifdef USE_ESP32
TaskHandle_t update_task_handle_{nullptr};
TaskResult *task_result_{nullptr}; // set while a check runs on the update task
#endif
uint8_t initial_check_remaining_{0};
private:
const char *source_url_{nullptr}; // literal from codegen
friend class testing::HttpRequestUpdateTest;
void release_source_url_();
// A check reads the URL it started with until it ends, on the update task or inline through an
// automation it triggers. Ownership of that buffer moves to the check, so a set_source_url in the
// meantime cannot free it; end_check_() hands it back or frees it on the main loop.
void begin_check_(TaskResult *result);
void end_check_(TaskResult *result);
bool source_url_owned_{false}; // source_url_ was copied by a run-time setter; sits in the padding above
const char *source_url_{nullptr};
};
} // namespace esphome::http_request
+3 -1
View File
@@ -169,7 +169,9 @@ void LEDCOutput::setup() {
#if !defined(CLANG_TIDY)
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0)
PERIPH_RCC_ATOMIC() { ledc_ll_reset_register(0); }
#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) || \
(ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) && !defined(__PERIPH_CTRL_ALLOW_LEGACY_API))
// Also on the targets whose periph_module_reset() is an empty stub in ESP-IDF 5.x (P4, C5, C61, H4)
PERIPH_RCC_ATOMIC() {
ledc_ll_enable_reset_reg(true);
ledc_ll_enable_reset_reg(false);
+3
View File
@@ -1,6 +1,7 @@
#pragma once
#include "light_color_values.h"
#include "esphome/core/string_ref.h"
namespace esphome {
@@ -133,6 +134,8 @@ class LightCall {
LightCall &set_effect(const std::string &effect) { return this->set_effect(effect.data(), effect.size()); }
/// Set the effect of the light by its name (const char * overload to resolve ambiguity).
LightCall &set_effect(const char *effect) { return this->set_effect(effect, strlen(effect)); }
/// Set the effect of the light by its name (StringRef overload to resolve ambiguity, zero-copy).
LightCall &set_effect(StringRef effect) { return this->set_effect(effect.c_str(), effect.size()); }
/// Set the effect of the light by its name and length (zero-copy from API).
LightCall &set_effect(const char *effect, size_t len);
/// Set the effect of the light by its internal index number (only for internal use).
+1 -2
View File
@@ -169,8 +169,7 @@ bool MQTTFanComponent::publish_state() {
char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
const char *state_s = this->state_->state ? "ON" : "OFF";
ESP_LOGD(TAG, "'%s' Sending state %s.", this->state_->get_name().c_str(), state_s);
this->publish(this->get_state_topic_to_(topic_buf), state_s);
bool failed = false;
bool failed = !this->publish(this->get_state_topic_to_(topic_buf), state_s);
if (this->state_->get_traits().supports_direction()) {
bool success = this->publish(this->get_direction_state_topic_to(topic_buf),
fan_direction_to_mqtt_str(this->state_->direction));
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.10.0"
__version__ = "2026.10.1"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+18 -11
View File
@@ -648,6 +648,8 @@ class EsphomeCore:
self.main_statements: list[Statement] = []
# A list of statements to insert in the global block (includes and global variables)
self.global_statements: list[Statement] = []
# Generated code emitted after the user includes, so it can use what they declare
self.global_statements_after_includes: list[Statement] = []
# A map of platformio libraries to add to the project (shortname: (name, version, repository))
self.platformio_libraries: dict[str, Library] = {}
# A set of build flags to set in the platformio project
@@ -716,6 +718,7 @@ class EsphomeCore:
self.variables = {}
self.main_statements = []
self.global_statements = []
self.global_statements_after_includes = []
self.platformio_libraries = {}
self.build_flags = set()
self.cmake_args = {}
@@ -1032,8 +1035,8 @@ class EsphomeCore:
except RuntimeError as e:
raise EsphomeError(str(e)) from e
def add(self, expression, prepend=False) -> "Statement":
"""Add an expression or statement to the main setup() block."""
@staticmethod
def _as_statement(expression) -> "Statement":
from esphome.cpp_generator import Expression, Statement, statement
if isinstance(expression, Expression):
@@ -1042,7 +1045,11 @@ class EsphomeCore:
raise ValueError(
f"Add '{expression}' must be expression or statement, not {type(expression)}"
)
return expression
def add(self, expression, prepend=False) -> "Statement":
"""Add an expression or statement to the main setup() block."""
expression = self._as_statement(expression)
if prepend:
self.main_statements.insert(0, expression)
else:
@@ -1051,14 +1058,7 @@ class EsphomeCore:
return expression
def add_global(self, expression, prepend=False) -> "Statement":
from esphome.cpp_generator import Expression, Statement, statement
if isinstance(expression, Expression):
expression = statement(expression)
if not isinstance(expression, Statement):
raise ValueError(
f"Add '{expression}' must be expression or statement, not {type(expression)}"
)
expression = self._as_statement(expression)
if prepend:
self.global_statements.insert(0, expression)
else:
@@ -1066,6 +1066,13 @@ class EsphomeCore:
_LOGGER.debug("Adding global: %s", expression)
return expression
def add_global_after_includes(self, expression) -> "Statement":
"""Add a global after the user includes, for generated code that may use them."""
expression = self._as_statement(expression)
self.global_statements_after_includes.append(expression)
_LOGGER.debug("Adding global after includes: %s", expression)
return expression
def add_library(self, library: Library):
if not isinstance(library, Library):
raise TypeError(
@@ -1245,7 +1252,7 @@ class EsphomeCore:
from esphome.cpp_generator import statement
global_code = []
for exp in self.global_statements:
for exp in (*self.global_statements, *self.global_statements_after_includes):
text = str(statement(exp))
text = text.rstrip()
global_code.append(text)
+1 -1
View File
@@ -169,7 +169,7 @@ struct Color {
uint8_t r = rand >> 16;
uint8_t g = rand >> 8;
uint8_t b = rand >> 0;
const uint16_t max_rgb = std::max({r, g, b});
const uint16_t max_rgb = std::max({r, g, b, uint8_t(1)});
return Color(uint8_t((uint16_t(r) * 255U / max_rgb)), uint8_t((uint16_t(g) * 255U / max_rgb)),
uint8_t((uint16_t(b) * 255U / max_rgb)), w);
}
+2 -1
View File
@@ -577,7 +577,8 @@ async def add_arduino_global_workaround():
@coroutine_with_priority(CoroPriority.FINAL)
async def add_includes(includes: list[str], is_c_header: bool = False) -> None:
# Add includes at the very end, so that the included files can access global variables
# After the id declarations, so the headers can use id(x); the generated function bodies
# and flash tables come later still, so their lambdas can use the headers.
for include in includes:
path = CORE.relative_config_path(include)
if path.is_dir():
+20 -10
View File
@@ -318,6 +318,8 @@ class FloatLiteral(Literal):
def __str__(self):
if math.isnan(self.f):
return "NAN"
if math.isinf(self.f):
return "INFINITY" if self.f > 0 else "-INFINITY"
return f"{self.f}f"
@@ -506,8 +508,8 @@ def shared_progmem_array(
used |= {str(i) for i in CORE.variables}
used |= set(RESERVED_IDS) | CORE.loaded_integrations
id_ = ID(ensure_unique_string(name, used), is_declaration=True, type=type_)
# Global, so any scope can use it; anything a lambda references is already declared.
CORE.add_global(ProgmemAssignmentExpression(type_, id_, rhs))
# After the user includes, so a lambda in the table can use what their headers declare.
CORE.add_global_after_includes(ProgmemAssignmentExpression(type_, id_, rhs))
array = MockObj(id_, ".")
CORE.register_variable(id_, array)
if share:
@@ -731,15 +733,16 @@ def static_function(
"""Emit ``static <return_type> <name>(parameters) { body }`` at global scope and return an
expression naming it, for use as a template argument or a function pointer.
Every id the body names must already be declared, which holds when the statements were
rendered through ``get_variable`` or ``process_lambda``.
The declaration goes with the other globals, so a template argument can name the function
at once; the body goes after the user includes, where every id and everything those
headers declare is in scope.
"""
params = ParameterListExpression(*parameters)
add_global(
RawStatement(
f"static {safe_exp(return_type)} {name}({params}) {{\n"
f"{indent(chr(10).join(body))}\n}}"
)
signature = (
f"static {safe_exp(return_type)} {name}({ParameterListExpression(*parameters)})"
)
add_global(RawStatement(f"{signature};"))
add_global_after_includes(
RawStatement(f"{signature} {{\n{indent(chr(10).join(body))}\n}}")
)
return RawExpression(name)
@@ -758,6 +761,13 @@ def add_global(expression: SafeExpType | Statement, prepend: bool = False):
CORE.add_global(expression, prepend)
def add_global_after_includes(expression: SafeExpType | Statement):
"""Add a global after the user's ``includes:`` headers, for generated code whose
lambdas may use what those headers declare.
"""
CORE.add_global_after_includes(expression)
def add_library(name: str, version: str | None, repository: str | None = None):
"""Add a library to the codegen library storage.
+12 -2
View File
@@ -266,14 +266,24 @@ def _convert_pio_libs(
in the real esp32 build; converting them too would give IDF the same requirement
twice. Arduino below IDF 6.0 has them rule-gated off, so there they convert.
"""
from esphome.components.esp32 import arduino_bundles_libsodium
from esphome.components.esp32 import (
ARDUINO_DISABLED_LIBRARIES,
arduino_bundles_libsodium,
)
from esphome.espidf.component import generate_idf_components
libraries = _parse_lib_deps(platformio_ini, framework)
# Same rule as noise._use_managed_components and the manifest
managed = set() if arduino_bundles_libsodium() else _esphome_manifest_deps()
deps: dict[str, dict[str, str]] = {}
for component in generate_idf_components(libraries, managed=managed):
# As in the real esp32 build, a dependency on an Arduino-bundled library is
# left to arduino-esp32 instead of being fetched from the registry
provides = (
ARDUINO_DISABLED_LIBRARIES.__contains__ if framework == "arduino" else None
)
for component in generate_idf_components(
libraries, managed=managed, provides=provides
):
deps[component.get_sanitized_name()] = {"override_path": str(component.path)}
return deps
+7 -1
View File
@@ -7,6 +7,7 @@ for each resolved library, running any PlatformIO ``extraScript``, and the
ESP-IDF platform/framework compatibility defaults.
"""
from collections.abc import Callable
import logging
import os
from pathlib import Path
@@ -288,18 +289,23 @@ def _emit_idf_component(component: IDFComponent) -> None:
def generate_idf_components(
libraries: list[Library], managed: set[str] | None = None
libraries: list[Library],
managed: set[str] | None = None,
provides: Callable[[str], bool] | None = None,
) -> list[IDFComponent]:
"""Resolve and convert a batch of PlatformIO libraries to IDF components.
``managed`` names registry components already in the project manifest; they
are not converted, since IDF rejects the same requirement from two components.
Converted components reach them through ``${ESPHOME_PROJECT_MANAGED_COMPONENTS}``.
``provides`` names the libraries the framework bundles, so a manifest dependency on
one is never fetched from the registry.
"""
backend = LibraryBackend(
platform=ESP32_PLATFORM,
framework=_idf_framework(),
emit=_emit_idf_component,
cache_key="idf",
provides=provides,
)
return convert_libraries(libraries, backend, provided=managed)
+23 -9
View File
@@ -205,28 +205,42 @@ def run_command(
return False, None, None
def tool_version_runs(binary: str, warning: str) -> bool:
"""Probe ``binary --version``; on failure warn with ``warning`` % binary.
def tool_version_probe(binary: str) -> str | Exception:
"""Run ``binary --version``: its stdout, or the error when it fails to run.
``shutil.which`` proves existence, not runnability (Windows .bat/.cmd
shims, stale package-manager shims).
"""
try:
subprocess.run(
result = subprocess.run(
[binary, "--version"],
check=True,
stdout=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
errors="replace",
timeout=15,
# Repo-wide convention (posix_spawn fast path)
close_fds=False,
)
except (OSError, subprocess.SubprocessError) as err:
# The cause (permission denied, missing DLL, timeout) is the one
# detail the user needs to fix it
_LOGGER.warning("%s (%s)", warning % binary, err)
return False
return True
return err
return result.stdout
def tool_version_runs(
binary: str,
warning: str,
probe: Callable[[str], str | Exception] = tool_version_probe,
) -> bool:
"""Probe ``binary --version``; on failure warn with ``warning`` % binary.
``probe`` lets a caller supply a cached probe."""
if not isinstance(result := probe(binary), Exception):
return True
# The cause (permission denied, missing DLL, timeout) is the one detail
# the user needs to fix it
_LOGGER.warning("%s (%s)", warning % binary, result)
return False
def run_command_ok(*args, **kwargs) -> bool:
+251 -101
View File
@@ -609,9 +609,34 @@ def parse_library_properties(library_properties_path: PathType):
if not value.strip():
continue
data[key.strip()] = value.strip()
if depends := data.pop("depends", None):
data["dependencies"] = parse_properties_depends(depends)
return data
def parse_properties_depends(depends: str) -> list[dict[str, str]]:
"""The Arduino ``depends=Name, Other (>=1.0)`` line as name-only dependency entries.
An Arduino constraint is not PlatformIO syntax, so it is dropped; the name is
satisfied by a listed library or the registry's current version, as the walk
does for every version-less dependency.
"""
entries: list[dict[str, str]] = []
for item in depends.split(","):
name, _, constraint = item.strip().partition("(")
if not (name := name.strip()):
continue
if constraint:
# A plain lower bound is met by the registry's current version; an
# exact or capped one may not be, so say so
log = (
_LOGGER.debug if constraint.lstrip().startswith(">=") else _LOGGER.info
)
log("Ignoring version constraint on depends=%s", item.strip())
entries.append({"name": name})
return entries
def _make_registry_client() -> Any:
"""Create a minimal PlatformIO registry client with no system filtering.
@@ -730,20 +755,6 @@ def join_flag_args(tokens: Iterable[str], owner: str) -> list[str]:
return out
def warn_properties_depends(name: str, data: object) -> None:
"""Warn for ``depends=``-only manifests; the walk reads only the JSON
``dependencies`` key, so they would otherwise drop silently."""
if isinstance(data, dict) and not data.get("dependencies") and data.get("depends"):
# INFO: common and unactionable for transitive libraries; a WARNING
# on every build would train users to ignore the stream
_LOGGER.info(
"Library %s declares dependencies via library.properties "
"depends=, which are not resolved automatically; add them with "
"add_library() if needed",
name,
)
def dependency_is_usable(
dep: dict, platform: str | None, framework: str, requester: str
) -> bool:
@@ -762,6 +773,20 @@ def dependency_is_usable(
return True
def _split_owner(entry: dict) -> dict:
"""``owner/Name`` written as one name becomes owner and name, as in the dict form,
so the name matches the package the registry resolves it to."""
name, owner = entry.get("name"), entry.get("owner")
if isinstance(name, str) and owner is None and "://" not in name:
head, sep, tail = name.partition("/")
# A registry owner is a plain identifier; anything else is left to the
# backend's own checks
owner_ok = head.replace("-", "").replace("_", "").isalnum()
if sep and owner_ok and tail and "/" not in tail:
return {**entry, "owner": head, "name": tail}
return entry
def _valid_dependency_entry(entry: dict, manifest_name: str) -> bool:
"""Whether a normalized entry carries a usable name (non-empty string),
version (string, if present), and owner (string, if present); invalid
@@ -790,16 +815,12 @@ def normalize_dependencies(
so callers see a uniform list. ``manifest_name`` names the manifest in the
warning for entries that cannot be normalized.
Bare-name spellings carry no version; the dependency walk later drops
version-less entries at DEBUG, since they are usually names of bundled
framework libraries (Wire, SPI) that need no registry install.
Bare-name spellings carry no version; the walk settles them once the graph is known.
"""
if dependencies is None:
return []
if isinstance(dependencies, str):
# A plain string is one or more comma-separated names; iterating it
# as a list would shred it into one-character "libraries"
return [{"name": n.strip()} for n in dependencies.split(",") if n.strip()]
return [_split_owner(entry) for entry in parse_properties_depends(dependencies)]
if isinstance(dependencies, dict):
normalized = []
for raw_name, spec in dependencies.items():
@@ -826,10 +847,10 @@ def normalize_dependencies(
for entry in dependencies:
if isinstance(entry, dict):
if _valid_dependency_entry(entry, manifest_name):
normalized.append(entry)
normalized.append(_split_owner(entry))
elif isinstance(entry, str) and entry:
# PIO also accepts a bare list of names ("dependencies": ["Wire"])
normalized.append({"name": entry})
normalized.append(_split_owner({"name": entry}))
else:
_LOGGER.warning(
"Ignoring unrecognized dependency entry %r of %s",
@@ -971,54 +992,61 @@ def is_lib_ignored(name: str | None, lib_ignore: set[str]) -> bool:
)
def _reconcile_versionless_skips(
skipped_versionless: list[tuple[Any, Any, str]],
def _short_name(node: _LibNode) -> str:
"""The package name a node goes by: the registry name, else the last segment
of its key (the repository or directory name of a git or local source)."""
return node.pkgname or node.key.rpartition("/")[2]
def _satisfying_key(
name: str,
owner: str | None,
requester: str,
nodes: dict[str, _LibNode],
components: dict[str, ConvertedLibrary],
backend: LibraryBackend,
) -> None:
"""Warn for version-less deps nothing satisfied, and record the
backend-provided ones in ``backend.provided_requests`` for its
post-emit reconciliation; a silent drop surfaces as link errors far
from the cause."""
resolved_manifest_names = {c.data.get("name") for c in components.values()}
# A treeless backend can never supply a bundled name; noise for it
log = _LOGGER.warning if backend.provides is not None else _LOGGER.debug
warned: set[str] = set()
for dep_name, dep_owner, requester in skipped_versionless:
if not isinstance(dep_name, str) or not dep_name or dep_name in warned:
continue
if dep_name in components:
# A version-less dep's request key is the name itself
continue
if (
not dep_owner
and backend.provides is not None
and backend.provides(dep_name)
):
# provides() only satisfies owner-less names (same guard as
# the walk's skip); record for the post-emit reconciliation.
# Checked before the manifest-name evidence so the overlap
# case warns once, in the backend's own suppression loop
backend.provided_requests.add(dep_name)
continue
if dep_name in resolved_manifest_names:
# Name-only evidence: a coincidental collision must stay
# visible where the user could pin it
warned.add(dep_name)
log(
"Version-less dependency %s of %s assumed satisfied by a "
"resolved library's manifest name only",
dep_name,
requester,
)
continue
warned.add(dep_name)
log(
"Dependency %s of %s has no version to resolve and nothing "
"provides it; skipping",
dep_name,
requester,
)
) -> tuple[str, bool] | None:
"""The key of a resolved library a version-less dependency names, and whether
only its manifest name matched: the registry package name (with the owner,
when the dependency gives one) is checked first; never the requester itself."""
wanted = name.lower()
def owner_of(key: str, component: ConvertedLibrary) -> str:
# The owner as requested, else the one the registry (or a git path) gave
head, sep, _ = component.name.partition("/")
return nodes[key].owner or (head if sep else "")
candidates = [
(key, component)
for key, component in components.items()
if key != requester
and (not owner or owner_of(key, component).lower() == owner.lower())
]
for key, _ in candidates:
if _short_name(nodes[key]).lower() == wanted:
return key, False
if owner:
return None
for key, component in candidates:
manifest_name = component.data.get("name")
if isinstance(manifest_name, str) and manifest_name.lower() == wanted:
return key, True
return None
def _own_names(
key: str, nodes: dict[str, _LibNode], components: dict[str, ConvertedLibrary]
) -> set[str]:
"""The lower-case key, requested and resolved package names and the manifest
name a resolved library goes by."""
names = {
key.lower(),
_short_name(nodes[key]).lower(),
components[key].name.lower(),
}
manifest_name = components[key].data.get("name")
if isinstance(manifest_name, str):
names.add(manifest_name.lower())
return names
def _fetch_source(
@@ -1147,7 +1175,12 @@ def convert_libraries(
managed components from ``add_idf_component``); unlike ``backend.provides`` they
carry an owner. They are excluded like ``lib_ignore`` so nothing is both
converted and managed, which ESP-IDF refuses to build.
A dependency named without a version is settled once the graph is known: wired
to a library already in it, left to ``backend.provides``, or fetched by name.
"""
from platformio.package.exception import UnknownPackageError
nodes: dict[str, _LibNode] = {}
# Folded into one set so every is_lib_ignored() call site honors both.
@@ -1165,10 +1198,16 @@ def convert_libraries(
else ""
)
resolved_requirements: dict[str, frozenset[str]] = {}
def add_spec(name: str | None, version: str | None, repository: str | None) -> str:
key, kind, locator = _node_key(name, version, repository)
node = nodes.get(key) or _LibNode(key=key, is_git=kind == "git")
nodes[key] = node
if kind != "registry" and not (node.is_git or node.is_local):
# A registry node (perhaps one the registry did not know) becomes a
# git or local source: resolve it again from that source
resolved_requirements.pop(key, None)
# The same key requested from two different kinds of source (or two
# different local paths) is a config mistake: one silently wins. Warn so
# it isn't a surprise. (git-vs-registry is reported separately below.)
@@ -1220,10 +1259,73 @@ def convert_libraries(
# requirement set has grown since the last time, so every requirement in the
# graph is accounted for before conversion.
components: dict[str, ConvertedLibrary] = {}
resolved_requirements: dict[str, frozenset[str]] = {}
top_level_keys = set(top_level)
# (name, owner, requester) reconciled against the final resolution set
skipped_versionless: list[tuple[Any, Any, str]] = []
def fetched_by_name(key: str) -> bool:
"""A registry node only version-less manifest dependencies asked for: a
miss skips it, and an explicit library of the same name replaces it."""
node = nodes[key]
return (
key not in top_level_keys
and not (node.is_git or node.is_local)
and not node.requirements
)
def backend_provides(name: str, owner: str | None) -> bool:
"""Whether the framework bundles an owner-less name; asked once per name,
since the backend may warn, and recorded in ``provided_requests``."""
if owner or backend.provides is None:
return False
if name in backend.provided_requests or backend.provides(name):
backend.provided_requests.add(name)
return True
return False
# Version-less dependencies per requester key, kept for the whole walk and
# wired once the graph is final; a re-walked requester replaces its entries
pending_versionless: dict[str, list[dict]] = {}
# Names fetched by name so far: each is asked for once
fetched: dict[tuple[str, str], str] = {}
def spelling_of(name: str, owner: str | None) -> tuple[str, str]:
"""Registry names fold case; a URL names its source as written."""
return (name if "://" in name else name.lower(), (owner or "").lower())
def settle_versionless() -> list[str]:
"""Fetch by name the deferred dependencies nothing in the graph or the
framework satisfies yet; return the new keys to walk. Only a dependency
on the requester itself or with a bad name needs no wiring later."""
new_keys: list[str] = []
for requester, dependencies in pending_versionless.items():
kept: list[dict] = []
for dependency in dependencies:
name, owner = dependency["name"], dependency.get("owner")
if _owner_pkgname_to_name(owner, name).lower() in _own_names(
requester, nodes, components
):
_LOGGER.debug("Dependency %s of %s names itself", name, requester)
continue
# A library in the graph beats a bundled copy, as on the Arduino backend
if _satisfying_key(
name, owner, requester, nodes, components
) is None and not backend_provides(name, owner):
spelling = spelling_of(name, owner)
if spelling not in fetched:
try:
dep_key = add_spec(
_owner_pkgname_to_name(owner, name), None, None
)
except RuntimeError as e:
_LOGGER.warning(
"Skipping dependency %s of %s: %s", name, requester, e
)
continue
fetched[spelling] = dep_key
new_keys.append(dep_key)
kept.append(dependency)
dependencies[:] = kept
return new_keys
worklist = deque(dict.fromkeys(top_level))
while worklist:
# Drain the frontier sequentially (spec resolution mutates shared
@@ -1245,9 +1347,17 @@ def convert_libraries(
elif node.is_local:
component = ConvertedLibrary(key, "*", LocalSource(node.local_path))
else:
owner, name, version, url, size = _resolve_registry_version(
node.owner, node.pkgname, node.requirements
)
try:
owner, name, version, url, size = _resolve_registry_version(
node.owner, node.pkgname, node.requirements
)
except UnknownPackageError as e:
# A requirement conflict is a RuntimeError and always raises
if not fetched_by_name(key):
raise
# Reported per requester once the graph is final
_LOGGER.debug("Library %s is not in the registry: %s", key, e)
continue
component = ConvertedLibrary(
_owner_pkgname_to_name(owner, name), version, URLSource(url, size)
)
@@ -1255,9 +1365,9 @@ def convert_libraries(
_prefetch_wave(wave, salt, backend.cache_key)
for key, component in wave:
node = nodes[key]
if frozenset(node.requirements) != resolved_requirements[key]:
# Requirements grew mid-wave: skip parsing a manifest the
# next wave will re-resolve and replace
if frozenset(node.requirements) != resolved_requirements.get(key):
# Requirements grew (or the source changed) mid-wave: skip parsing
# a manifest the next wave will re-resolve and replace
worklist.append(key)
continue
component.download(salt=salt, namespace=backend.cache_key)
@@ -1313,8 +1423,6 @@ def convert_libraries(
raise EsphomeError(f"Library {key} has a malformed manifest")
_LOGGER.warning("Skipping dependency %s: malformed manifest", key)
continue
warn_properties_depends(component.name, component.data)
try:
check_library_data(component.data, backend.platform, backend.framework)
except InvalidLibrary as e:
@@ -1337,27 +1445,19 @@ def convert_libraries(
# Requirements changed (we got past the short-circuit above), so
# (re)walk this component's dependencies.
node.edges = set()
pending = pending_versionless[key] = []
for dependency in normalize_dependencies(
component.data.get("dependencies"), component.name
):
if "version" not in dependency:
# Cannot resolve from the registry; the post-emit
# reconciliation owns the drop warning
dep_name = dependency.get("name")
_LOGGER.debug(
"Skip version-less dependency %r of %s",
dep_name,
component.name,
)
if not dependency.get("version"):
# An empty version is as good as none; it settles the same way
if not is_lib_ignored(
dep_name, lib_ignore
dependency.get("name"), lib_ignore
) and dependency_is_usable(
dependency, backend.platform, backend.framework, component.name
):
# Filtered or ignored deps are deliberately absent
skipped_versionless.append(
(dep_name, dependency.get("owner"), component.name)
)
pending.append(dependency)
continue
if not dependency_is_usable(
dependency, backend.platform, backend.framework, component.name
@@ -1375,11 +1475,7 @@ def convert_libraries(
dep_url = _url_or_none(dep_version)
if dep_url is not None:
dep_version = None
elif (
backend.provides is not None
and not dependency.get("owner")
and backend.provides(dep_name)
):
elif backend_provides(dep_name, dependency.get("owner")):
# The backend adds it from its own tree; resolving here
# would fetch a same-named registry package
if dep_version and dep_version != "*":
@@ -1392,11 +1488,12 @@ def convert_libraries(
)
else:
_LOGGER.debug("Skip backend-provided dependency %s", dep_name)
backend.provided_requests.add(dep_name)
continue
dep_key = add_spec(dep_name, dep_version, dep_url)
node.edges.add(dep_key)
worklist.append(dep_key)
if not worklist:
worklist.extend(settle_versionless())
# A git or local source wins over the same component requested from the
# registry. That's intentional, but warn so the dropped registry spec isn't
@@ -1420,6 +1517,61 @@ def convert_libraries(
source,
)
# Every version-less request resolves to the explicit library that goes by
# its name if the final graph has one, else to the node fetched by name for
# that package, else to the framework's bundled copy, else to nothing
explicit = {k: c for k, c in components.items() if not fetched_by_name(k)}
by_name = {k: c for k, c in components.items() if fetched_by_name(k)}
unresolved: dict[tuple[str, str], None] = {}
for requester, dependencies in pending_versionless.items():
for dependency in dependencies:
name, owner = dependency["name"], dependency.get("owner")
found = _satisfying_key(
name, owner, requester, nodes, explicit
) or _satisfying_key(name, owner, requester, nodes, by_name)
if found is None:
# A dependency written as a URL goes by the key its source gave it
key = fetched.get(spelling_of(name, owner))
if key in components and key != requester:
found = (key, False)
if found is None:
if not backend_provides(name, owner):
unresolved[(requester, name)] = None
continue
key, by_manifest_name = found
if by_manifest_name:
# Visible, so a chance name collision can be pinned to the right package
_LOGGER.info(
"Dependency %s of %s satisfied by %s through its manifest name",
name,
requester,
key,
)
else:
_LOGGER.debug(
"Dependency %s of %s satisfied by %s", name, requester, key
)
nodes[requester].edges.add(key)
# What no listed library needs any more is not built: a node fetched by name
# that an explicit library replaced, and whatever only it pulled in
reachable: set[str] = set()
stack = [key for key in top_level if key in components]
while stack:
if (key := stack.pop()) in reachable:
continue
reachable.add(key)
stack.extend(edge for edge in nodes[key].edges if edge in components)
for key in [key for key in components if key not in reachable]:
_LOGGER.debug("Library %s is not needed; dropping it", key)
del components[key]
for requester, name in unresolved:
if requester in components:
_LOGGER.warning(
"Dependency %s of %s was not resolved; skipping", name, requester
)
# Two graph nodes that resolve to the same component name (e.g. a package
# referenced both bare and as ``owner/name``) are not deduplicated and can
# produce conflicting component definitions. Warn so it's not silent.
@@ -1447,6 +1599,4 @@ def convert_libraries(
for component in components.values():
backend.emit(component)
_reconcile_versionless_skips(skipped_versionless, components, backend)
return [components[key] for key in top_level if key in components]
+11
View File
@@ -190,8 +190,19 @@ class RedirectText:
"""Whether output is handled line by line rather than passed through."""
return self._filter_pattern is not None or bool(self._line_callbacks)
def _drop_line(self, line: str) -> None:
"""Leave out a filtered line, keeping the color codes it carried.
cmake ends a warning with the reset code on a line of its own; dropped
with the blank lines, it would leave everything after it in the
warning's color.
"""
if codes := "".join(ANSI_ESCAPE.findall(line)):
self._write_color_replace(codes)
def _emit_line(self, line: str) -> None:
if self._is_filtered(line):
self._drop_line(line)
return
line_without_end = ANSI_ESCAPE.sub("", line).rstrip()
+8
View File
@@ -100,6 +100,14 @@ button:
id: climate_test_thermostat
custom_fan_mode: !lambda return "quiet";
custom_preset: !lambda return "Default";
# Inlined lambdas returning a StringRef, the shape an api action string variable takes (issue #21327)
- platform: template
name: "Climate Control StringRef Lambdas"
on_press:
- climate.control:
id: climate_test_thermostat
custom_fan_mode: !lambda return id(climate_test_thermostat).get_custom_fan_mode();
custom_preset: !lambda return id(climate_test_thermostat).get_custom_preset();
# Exercise climate.control inside a trigger with non-empty Ts (number on_value
# passes float).
+15
View File
@@ -43,6 +43,21 @@ button:
url: http://my.ha.net:8123/local/esphome/firmware.bin
- logger.log: "This message should be not displayed (reboot)"
# Device firmware switches the manifest from lambdas; every shape below must compile and
# the stored URL must outlive the lambda (EverythingSmartHome/everything-presence-one#341).
- platform: template
name: Switch manifest
on_press:
then:
- lambda: |-
id(ota_update).set_source_url("http://my.ha.net:8123/local/esphome/beta-manifest.json");
std::string url = "http://my.ha.net:8123/local/esphome/";
url += "manifest.json";
id(ota_update).set_source_url(url);
id(ota_update).set_source_url(url.c_str());
id(ota_update).set_source_url(std::string("http://my.ha.net:8123/local/esphome/manifest.json"));
ESP_LOGI("test", "Manifest is now %s", id(ota_update).get_source_url());
- component.update: ota_update
update:
- platform: http_request
@@ -0,0 +1,8 @@
import esphome.config_validation as cv
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The real schema rejects the host target by framework version; the update platform's
# unit tests only need this platform's sources on the host.
manifest.config_schema = cv.Schema({}, extra=cv.ALLOW_EXTRA)
@@ -0,0 +1,141 @@
#include <gtest/gtest.h>
#include <string>
#include "esphome/components/http_request/update/http_request_update.h"
#if defined(__SANITIZE_ADDRESS__)
#define HAS_ASAN
#elif defined(__has_feature)
#if __has_feature(address_sanitizer)
#define HAS_ASAN
#endif
#endif
#ifdef HAS_ASAN
#include <sanitizer/asan_interface.h>
#endif
namespace esphome::http_request::testing {
namespace {
constexpr char LITERAL[] = "http://example.invalid/manifest.json";
// Freed heap memory is poisoned under AddressSanitizer, which CI builds with; without it the
// freed-exactly-once checks cannot be observed and are skipped.
void expect_freed(const char *buffer) {
#ifdef HAS_ASAN
EXPECT_TRUE(__asan_address_is_poisoned(buffer));
#endif
}
void expect_live(const char *buffer, const char *content) {
#ifdef HAS_ASAN
EXPECT_FALSE(__asan_address_is_poisoned(buffer));
#endif
EXPECT_STREQ(buffer, content);
}
} // namespace
// Friend of HttpRequestUpdate, so a check can be begun and ended without an HTTP fetch.
class HttpRequestUpdateTest : public ::testing::Test {
protected:
static void begin_check(HttpRequestUpdate &update, TaskResult &result) { update.begin_check_(&result); }
static void end_check(HttpRequestUpdate &update, TaskResult &result) { update.end_check_(&result); }
};
TEST(HttpRequestUpdateSourceUrl, ConfigLiteralIsKeptByPointer) {
HttpRequestUpdate update;
update.set_source_url_static(LITERAL);
EXPECT_EQ(update.get_source_url(), LITERAL);
}
TEST(HttpRequestUpdateSourceUrl, RuntimeStringIsCopied) {
HttpRequestUpdate update;
std::string url = "http://example.invalid/from-string-manifest.json";
update.set_source_url(url);
EXPECT_NE(update.get_source_url(), url.c_str());
EXPECT_STREQ(update.get_source_url(), url.c_str());
}
// Device firmware passes base_url.c_str() of a local and runs the update after the lambda returned.
TEST(HttpRequestUpdateSourceUrl, PointerIntoDeadLocalStaysReadable) {
HttpRequestUpdate update;
{
std::string local = "http://example.invalid/from-local-manifest.json";
update.set_source_url(local.c_str());
}
EXPECT_STREQ(update.get_source_url(), "http://example.invalid/from-local-manifest.json");
}
TEST(HttpRequestUpdateSourceUrl, PassingTheCurrentUrlBackIsSafe) {
HttpRequestUpdate update;
update.set_source_url("http://example.invalid/current-manifest.json");
const char *previous = update.get_source_url();
update.set_source_url(update.get_source_url());
EXPECT_STREQ(update.get_source_url(), "http://example.invalid/current-manifest.json");
expect_freed(previous);
}
TEST(HttpRequestUpdateSourceUrl, ConfigLiteralAfterRuntimeUrlDropsTheCopy) {
HttpRequestUpdate update;
update.set_source_url("http://example.invalid/runtime-manifest.json");
const char *copy = update.get_source_url();
update.set_source_url_static(LITERAL);
EXPECT_EQ(update.get_source_url(), LITERAL);
expect_freed(copy);
}
// The URL a check started with stays readable until the check ends, however often it is replaced in
// the meantime; the replacements it never saw are freed at once and the original exactly once.
TEST_F(HttpRequestUpdateTest, UrlReplacedDuringACheckOutlivesIt) {
HttpRequestUpdate update;
update.set_source_url("http://example.invalid/first-manifest.json");
const char *first = update.get_source_url();
TaskResult result;
begin_check(update, result);
EXPECT_EQ(result.source_url, first);
update.set_source_url("http://example.invalid/second-manifest.json");
const char *second = update.get_source_url();
expect_live(first, "http://example.invalid/first-manifest.json");
update.set_source_url("http://example.invalid/third-manifest.json");
const char *third = update.get_source_url();
expect_live(first, "http://example.invalid/first-manifest.json");
expect_freed(second);
end_check(update, result);
expect_freed(first);
expect_live(third, "http://example.invalid/third-manifest.json");
// The entity owns the URL set during the check: switching away frees it.
update.set_source_url_static(LITERAL);
expect_freed(third);
}
TEST_F(HttpRequestUpdateTest, UrlUnchangedDuringACheckReturnsToTheEntity) {
HttpRequestUpdate update;
update.set_source_url("http://example.invalid/kept-manifest.json");
const char *kept = update.get_source_url();
TaskResult result;
begin_check(update, result);
end_check(update, result);
expect_live(kept, "http://example.invalid/kept-manifest.json");
// Ownership came back, so the next change frees it instead of leaking it.
update.set_source_url_static(LITERAL);
expect_freed(kept);
}
TEST_F(HttpRequestUpdateTest, ConfigLiteralDuringACheckIsNeverFreed) {
HttpRequestUpdate update;
update.set_source_url_static(LITERAL);
TaskResult result;
begin_check(update, result);
update.set_source_url("http://example.invalid/runtime-manifest.json");
end_check(update, result);
EXPECT_STREQ(LITERAL, "http://example.invalid/manifest.json");
EXPECT_STREQ(update.get_source_url(), "http://example.invalid/runtime-manifest.json");
}
} // namespace esphome::http_request::testing
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# setup() registers the entity as an OTA state listener, which the real to_code
# requests through ota.request_ota_state_listeners().
async def to_code_testing(config):
cg.add_define("USE_OTA_STATE_LISTENER")
manifest.to_code = to_code_testing
@@ -0,0 +1,2 @@
packages:
ledc: !include common.yaml
+4
View File
@@ -138,6 +138,10 @@ esphome:
- light.turn_on:
id: test_monochromatic_light
effect: !lambda 'return "Strobe";'
# Inlined lambda returning a StringRef, the shape an api action string variable takes (issue #21327)
- light.turn_on:
id: test_monochromatic_light
effect: !lambda 'return light::EFFECT_NONE_REF;'
# Test effect lambda with non-empty args (repeat passes uint32_t iteration)
- repeat:
count: 3
+4
View File
@@ -236,6 +236,10 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
external_components_path = str(FIXTURES_DIR / "external_components")
content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path)
# Replace the user includes path placeholder if present
if "INCLUDES_PATH" in content:
content = content.replace("INCLUDES_PATH", str(FIXTURES_DIR / "includes"))
if shared_name is not None:
# _compile verifies the marked test compiles this content unmodified
request.node._shared_yaml_content = content
@@ -0,0 +1,6 @@
#pragma once
static constexpr int HEADER_CONSTANT = 42;
// Names the global by its id, so the header must come after the id declarations.
inline int header_reads_global() { return header_value->value() * 2; }
@@ -0,0 +1,27 @@
esphome:
name: includes-lambda-order
includes:
- INCLUDES_PATH/lambda_order.h
on_boot:
# An apply action value is rendered at file scope.
- globals.set:
id: header_value
value: !lambda return HEADER_CONSTANT;
host:
api:
logger:
globals:
- id: header_value
type: int
initial_value: "0"
sensor:
- platform: template
name: Header Value
update_interval: 100ms
lambda: return header_reads_global();
filters:
# A lambda in a flash table.
- filter_out: !lambda return HEADER_CONSTANT - 1;
@@ -0,0 +1,31 @@
"""Lambdas rendered at file scope can use the user's includes, which can name ids."""
from __future__ import annotations
from aioesphomeapi import SensorInfo, SensorState
import pytest
from .state_utils import require_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_includes_lambda_order(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""The apply action and the sensor filter use the header, the header reads the global."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
sensor = require_entity(entities, "header_value", SensorInfo)
state = await wait_for_state(
client,
lambda s: (
isinstance(s, SensorState)
and s.key == sensor.key
and not s.missing_state
),
)
# globals.set stored HEADER_CONSTANT and the header read it back doubled.
assert state.state == 84.0
+53 -21
View File
@@ -5,10 +5,11 @@ from __future__ import annotations
import json
import logging
from pathlib import Path
from unittest.mock import patch
from unittest.mock import Mock, patch
import pytest
from esphome.build_helpers import pch
from esphome.components.esp32 import (
KEY_COMPONENTS,
KEY_ESP32,
@@ -609,11 +610,39 @@ def test_component_cmakelists_pch_block(monkeypatch: pytest.MonkeyPatch) -> None
assert "target_precompile_headers" not in get_component_cmakelists()
def test_component_cmakelists_pch_gate_on_windows(
windows_gcc_rule: None, monkeypatch: pytest.MonkeyPatch
def test_component_cmakelists_pch_ccache_gate(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The block carries the rule and records its choice; the knob drops
the gate."""
"""With the knob unset, CMake checks the ccache IDF found and records its
choice; the knob drops the gate."""
from esphome.build_gen.espidf import get_component_cmakelists
monkeypatch.delenv("ESPHOME_PCH_ENABLE")
# Off Windows, so the GCC rule stays out of the block
monkeypatch.setattr(pch.sys, "platform", "linux")
# Code generation must not run ccache or need an installed toolchain
monkeypatch.setattr(pch, "ccache_breaks_pch", Mock(side_effect=AssertionError))
content = get_component_cmakelists()
assert (
"if(CCACHE_ENABLE AND CCACHE_FOUND)\n"
' execute_process(COMMAND "${CCACHE_FOUND}" --version '
"OUTPUT_VARIABLE _esphome_ccache ERROR_QUIET)\n"
' if(_esphome_ccache MATCHES "ccache version ([0-9.]+)")\n'
" if(CMAKE_MATCH_1 VERSION_LESS 4.7)\n"
) in content
assert (
'set(ESPHOME_PCH ${_esphome_pch} CACHE BOOL "ESPHome precompiled header in use" FORCE)\n'
"if(_esphome_pch)\n target_precompile_headers(${COMPONENT_LIB} PRIVATE\n"
) in content
assert "CMAKE_CXX_COMPILER_VERSION VERSION_LESS" not in content
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
content = get_component_cmakelists()
assert "CCACHE_FOUND" not in content
assert "\ntarget_precompile_headers(${COMPONENT_LIB} PRIVATE\n" in content
def test_component_cmakelists_pch_gate_on_windows(windows_gcc_rule: None) -> None:
"""On Windows the GCC rule joins the ccache check."""
from esphome.build_gen.espidf import get_component_cmakelists
content = get_component_cmakelists()
@@ -622,39 +651,42 @@ def test_component_cmakelists_pch_gate_on_windows(
"(CMAKE_CXX_COMPILER_VERSION VERSION_LESS 14.4 OR "
"(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 15 AND "
"CMAKE_CXX_COMPILER_VERSION VERSION_LESS 15.3)))\n"
" message(STATUS " in content
)
" message(STATUS "
) in content
assert (
' set(ESPHOME_PCH OFF CACHE BOOL "ESPHome precompiled header in use" FORCE)\nelse()\n'
" set(_esphome_pch OFF)\nendif()\nif(CCACHE_ENABLE AND CCACHE_FOUND)"
in content
)
assert (
' set(ESPHOME_PCH ON CACHE BOOL "ESPHome precompiled header in use" FORCE)\n target_precompile_headers(${COMPONENT_LIB} PRIVATE\n'
in content
)
assert "endif()" in content
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
content = get_component_cmakelists()
assert "if(CMAKE_CXX_COMPILER_VERSION" not in content
assert "\ntarget_precompile_headers(${COMPONENT_LIB} PRIVATE\n" in content
@pytest.mark.parametrize(("choice", "written"), [("OFF", False), ("ON", True)])
def test_pch_checksum_follows_the_cmake_choice_on_windows(
windows_gcc_rule: None, tmp_path: Path, choice: str, written: bool
@pytest.mark.parametrize(
("choice", "logged"), [("OFF", False), ("ON", True), (None, False)]
)
def test_pch_checksum_ignores_the_cmake_choice(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
choice: str | None,
logged: bool,
) -> None:
"""The sum is always refreshed, so a reconfigure that turns the pch on,
or a build tree from before the gate, never pairs a new .gch with an old
sum; only the log line follows the choice."""
from esphome.build_gen.espidf import _PCH_SUM_PATH, write_pch_checksum
monkeypatch.delenv("ESPHOME_PCH_ENABLE")
_make_pch_project(tmp_path)
with (
patch(
"esphome.espidf.toolchain.get_cmake_cache_value", return_value=choice
) as asked,
patch.object(CORE, "name", "test"),
caplog.at_level("INFO"),
):
write_pch_checksum()
assert asked.call_args.args == ("ESPHOME_PCH",)
assert CORE.relative_build_path(_PCH_SUM_PATH).exists() is written
assert CORE.relative_build_path(_PCH_SUM_PATH).exists()
assert ("Compiling with a precompiled header" in caplog.text) is logged
@pytest.mark.parametrize(
+57 -1
View File
@@ -2,10 +2,12 @@
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
import os
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
@@ -195,3 +197,57 @@ def test_ccache_env_includes_pch_settings(tmp_path: Path) -> None:
env = _pch_ccache_env(tmp_path, {})
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
assert env["CCACHE_PCH_EXTSUM"] == "true"
@contextmanager
def _fake_ccache(
banner: str | Exception, env: dict[str, str] | None = None
) -> Iterator[MagicMock]:
"""A ccache on PATH whose ``--version`` prints ``banner``."""
with (
patch.dict(os.environ, env or {}, clear=True),
patch("shutil.which", return_value="/usr/bin/ccache"),
patch.object(ccache, "tool_version_probe", return_value=banner) as probe,
):
yield probe
@pytest.mark.parametrize(
("banner", "env", "expected"),
[
("ccache version 4.2\n", {}, True),
("ccache version 4.6.3\nFeatures: file-storage\n", {}, True),
# An explicit enable skips the runnability probe, not the version
("ccache version 4.6.3\n", {"ESPHOME_CCACHE_ENABLE": "1"}, True),
("ccache version 4.6.3\n", {"ESPHOME_CCACHE_ENABLE": "0"}, False),
("ccache version 4.7\n", {}, False),
("ccache version 4.13.6\n", {}, False),
("something else\n", {}, False),
],
)
def test_ccache_breaks_pch(banner: str, env: dict[str, str], expected: bool) -> None:
with _fake_ccache(banner, env):
assert ccache.ccache_breaks_pch() is expected
def test_ccache_breaks_pch_probes_and_logs_once(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The runnability probe and the version check share one spawn."""
with _fake_ccache("ccache version 4.5.1\n") as probe, caplog.at_level("INFO"):
assert ccache.resolve_ccache_path() == "/usr/bin/ccache"
assert ccache.ccache_breaks_pch()
assert ccache.ccache_breaks_pch()
probe.assert_called_once()
assert caplog.text.count("ccache 4.5.1 at /usr/bin/ccache") == 1
assert "upgrade ccache to 4.7 or newer" in caplog.text
def test_ccache_breaks_pch_forced_broken_binary_is_quiet(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Forced on, a ccache that fails to run is still used, so the version
check must not claim the build compiles without it."""
with _fake_ccache(OSError("boom"), {"ESPHOME_CCACHE_ENABLE": "1"}):
assert not ccache.ccache_breaks_pch()
assert "compiling without ccache" not in caplog.text
@@ -37,6 +37,21 @@ def test_pch_enabled(value: str | None, expected: bool) -> None:
assert pch.pch_enabled() is expected
@pytest.mark.parametrize(("value", "expected"), [(None, False), ("1", True)])
def test_old_ccache_rules_out_pch(value: str | None, expected: bool) -> None:
"""An old ccache turns the pch off unless ESPHOME_PCH_ENABLE=1 forces it."""
env = {} if value is None else {"ESPHOME_PCH_ENABLE": value}
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "rp2040"}
with (
patch.dict(os.environ, env, clear=True),
patch.object(pch, "ccache_breaks_pch", return_value=True),
patch.object(pch.sys, "platform", "linux"),
):
assert pch.pch_enabled()
assert pch.pch_usable(("g++",)) is expected
assert pch.pch_script_enabled() is expected
@pytest.mark.parametrize(
("version", "expected"),
[
@@ -176,6 +176,14 @@ def test_tool_output_filters_a_matching_progress_line() -> None:
assert out.getvalue() == "\r[2/2] shown\x1b[K"
def test_tool_output_keeps_the_color_codes_of_a_filtered_progress_line() -> None:
"""The hidden text and its newline go, the reset it carried stays."""
out = io.StringIO()
output = ToolOutput(out, [r"\[1/2\]"], True)
output.write("[1/2] hidden\x1b[0m\n[2/2] shown\n")
assert out.getvalue() == "\x1b[0m\r[2/2] shown\x1b[K"
def test_tool_output_without_progress_keeps_status_lines() -> None:
output, out = _tool_output(progress=False)
output.write("[1/2] Building a.o\n")
@@ -62,8 +62,9 @@ def test_write_idf_component_yml_passes_managed_components(
converted.get_sanitized_name.return_value = "esphome/other-lib"
converted.path = tmp_path / "pio_components" / "other-lib"
def fake_generate_idf_components(libraries, managed=None):
def fake_generate_idf_components(libraries, managed=None, provides=None):
captured["managed"] = managed
captured["provides"] = provides
return [converted]
monkeypatch.setattr(esp32, "generate_idf_components", fake_generate_idf_components)
@@ -71,6 +72,8 @@ def test_write_idf_component_yml_passes_managed_components(
esp32._write_idf_component_yml()
assert captured["managed"] == {"esphome/noise-c"}
# ESP-IDF bundles no Arduino libraries, so every name may be fetched
assert captured["provides"] is None
# The managed component itself is still written into the manifest deps
# directly (from KEY_COMPONENTS), just not converted a second time.
yml_path = tmp_path / "src" / "idf_component.yml"
@@ -93,7 +96,7 @@ def test_write_idf_component_yml_empty_managed_when_no_components(
captured: dict[str, set[str] | None] = {}
def fake_generate_idf_components(libraries, managed=None):
def fake_generate_idf_components(libraries, managed=None, provides=None):
captured["managed"] = managed
return []
@@ -104,6 +107,50 @@ def test_write_idf_component_yml_empty_managed_when_no_components(
assert captured["managed"] == set()
def test_write_idf_component_yml_marks_arduino_bundled_libraries_provided(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Under Arduino the converter is told which names the core bundles, so a
manifest dependency on Wire or SPI is never fetched from the registry."""
_setup_core(tmp_path)
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = str(Framework.ARDUINO)
CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version(3, 3, 12)
CORE.data[esp32.KEY_ESP32] = {
esp32.KEY_COMPONENTS: {},
esp32.KEY_IDF_VERSION: cv.Version(5, 5, 4),
esp32.KEY_ARDUINO_LIBRARIES: {"Wire", "SPI"},
}
captured: dict[str, object] = {}
def fake_generate_idf_components(libraries, managed=None, provides=None):
captured["provides"] = provides
return []
monkeypatch.setattr(esp32, "generate_idf_components", fake_generate_idf_components)
esp32._write_idf_component_yml()
provides = captured["provides"]
assert provides("Wire") and provides("SPI")
assert not provides("Adafruit BusIO")
def test_arduino_provides_warns_for_a_bundled_library_not_enabled(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A manifest dependency on a bundled Arduino library is never fetched, but
the library still has to be enabled, so an unenabled one is called out."""
CORE.reset()
CORE.data[esp32.KEY_ESP32] = {esp32.KEY_ARDUINO_LIBRARIES: {"Wire"}}
assert esp32._arduino_provides("Wire")
assert "Wire" not in caplog.text
assert esp32._arduino_provides("SPI")
assert "Arduino library SPI, which is not enabled" in caplog.text
assert not esp32._arduino_provides("Adafruit BusIO")
@pytest.mark.parametrize(
("version", "libsodium_stubbed"),
[
@@ -200,7 +247,9 @@ def test_write_idf_component_yml_stub_yields_to_converted_library_of_same_name(
converted.get_sanitized_name.return_value = "esphome/libsodium"
converted.path = tmp_path / "pio_components" / "esphome" / "libsodium"
monkeypatch.setattr(
esp32, "generate_idf_components", lambda libraries, managed=None: [converted]
esp32,
"generate_idf_components",
lambda libraries, managed=None, provides=None: [converted],
)
esp32._write_idf_component_yml()
+11
View File
@@ -199,6 +199,17 @@ def _default_pch_knobs(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
@pytest.fixture(autouse=True)
def _no_ambient_ccache(monkeypatch: pytest.MonkeyPatch) -> None:
"""The ccache on this machine does not decide the pch, and no cached
ccache probe crosses tests."""
from esphome.build_helpers import ccache, pch
monkeypatch.setattr(pch, "ccache_breaks_pch", lambda: False)
ccache._ccache_probe.cache_clear()
ccache._ccache_too_old_for_pch.cache_clear()
@pytest.fixture
def windows_gcc_rule(monkeypatch: pytest.MonkeyPatch) -> None:
"""Windows host, knob unset: the compiler version decides."""
+35 -1
View File
@@ -24,7 +24,7 @@ from esphome.const import (
KEY_TARGET_PLATFORM,
Toolchain,
)
from esphome.core import CORE, KEY_CONTROLLER_REGISTRY_CONTROLLERS, config
from esphome.core import CORE, ID, KEY_CONTROLLER_REGISTRY_CONTROLLERS, config
from esphome.core.config import (
CONF_SUSPEND_LOOP,
Area,
@@ -1006,6 +1006,40 @@ async def test_add_includes_with_single_file(
assert any('#include "my_header.h"' in inc for inc in includes_added)
@pytest.mark.asyncio
async def test_add_includes_sit_between_declarations_and_generated_code(
tmp_path: Path, mock_copy_file_if_changed: Mock
) -> None:
"""A header can name an id, and generated functions and tables can use the header."""
CORE.config_path = tmp_path / "config.yaml"
CORE.build_path = tmp_path / "build"
CORE.build_path.mkdir(parents=True, exist_ok=True)
header = tmp_path / "my_header.h"
header.write_text("static constexpr int MY_CONSTANT = 42;")
CORE.config = {}
value = cg.new_Pvariable(ID("my_value", is_declaration=True, type=cg.int32))
cg.static_function(
"esphome__my_action__fn", cg.void, [], [f"*{value} = MY_CONSTANT;"]
)
cg.shared_progmem_array("my_table", cg.int32, [cg.RawExpression("MY_CONSTANT")])
await config.add_includes([str(header)])
# A job that runs after the includes still lands before the generated code.
cg.add_global(cg.RawStatement("static constexpr int FINAL_JOB = 1;"))
section = CORE.cpp_global_section
expected_order = [
"*const my_value",
"static void esphome__my_action__fn();",
'#include "my_header.h"',
"static constexpr int FINAL_JOB = 1;",
"static void esphome__my_action__fn() {",
"my_table[] PROGMEM",
]
positions = [section.index(text) for text in expected_order]
assert positions == sorted(positions), section
@pytest.mark.asyncio
@pytest.mark.skipif(os.name == "nt", reason="Unix-specific test")
async def test_add_includes_with_directory_unix(
+3 -4
View File
@@ -846,15 +846,14 @@ def test_library_info_dot_a_linkage_malformed_raises(tmp_path: Path) -> None:
def test_bundled_library_properties_depends_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""The library.properties depends= spelling reaches the visibility
warning too; the shared parser returns it raw."""
"""The library.properties depends= spelling is a dependency list too, so a
bundled manifest using it gets the same visibility warning."""
framework = _make_framework(tmp_path)
wire = framework / "libraries" / "Wire"
(wire / "library.properties").write_text("name=Wire\nversion=1.0\ndepends=SPI\n")
_add_library("Wire", None)
caplog.set_level("INFO")
_resolve(framework)
assert "Library Wire declares dependencies via library.properties" in caplog.text
assert "Bundled library Wire declares dependencies" in caplog.text
def test_bundled_library_extra_script_raises(tmp_path: Path) -> None:
+26 -5
View File
@@ -512,7 +512,9 @@ class MockCodegen(NamedTuple):
new_pvariable: MagicMock
register_parented: AsyncMock
add_global: MagicMock
calls: MagicMock # new_pvariable and add_global attached, to check their order
add_global_after_includes: MagicMock
add: MagicMock
calls: MagicMock # the global emitters and new_pvariable attached, to check their order
@pytest.fixture
@@ -525,14 +527,25 @@ def mock_cg() -> Generator[MockCodegen]:
"esphome.codegen.register_parented", new_callable=AsyncMock
) as register_parented,
patch("esphome.cpp_generator.add_global") as add_global,
patch(
"esphome.cpp_generator.add_global_after_includes"
) as add_global_after_includes,
patch("esphome.codegen.add") as add,
):
get_variable.return_value = PARENT_OBJ
new_pvariable.return_value = NEW_OBJ
calls = MagicMock()
calls.attach_mock(new_pvariable, "new_pvariable")
calls.attach_mock(add_global, "add_global")
calls.attach_mock(add_global_after_includes, "add_global_after_includes")
yield MockCodegen(
get_variable, new_pvariable, register_parented, add_global, calls
get_variable,
new_pvariable,
register_parented,
add_global,
add_global_after_includes,
add,
calls,
)
@@ -660,7 +673,7 @@ async def _run_apply_condition(
def _apply_definition(mock_cg: MockCodegen) -> str:
return str(mock_cg.add_global.call_args.args[0])
return str(mock_cg.add_global_after_includes.call_args.args[0])
@pytest.mark.asyncio
@@ -674,8 +687,16 @@ async def test_register_apply_action_entry(
action_id, template_arg = mock_cg.new_pvariable.call_args.args
assert action_id == ID("obj_1")
assert str(template_arg) == "<esphome__obj_1__fn, int32_t>"
# The definition must precede the storage line that names the function.
assert [c[0] for c in mock_cg.calls.mock_calls] == ["add_global", "new_pvariable"]
# The declaration must precede the storage line that names the function; the body
# goes after the user includes.
assert [c[0] for c in mock_cg.calls.mock_calls] == [
"add_global",
"add_global_after_includes",
"new_pvariable",
]
assert str(mock_cg.add_global.call_args.args[0]) == (
"static void esphome__obj_1__fn(const std::remove_cvref_t<int32_t> & x);"
)
assert _apply_definition(mock_cg).startswith(
"static void esphome__obj_1__fn(const std::remove_cvref_t<int32_t> & x) {"
)
+7 -2
View File
@@ -346,6 +346,9 @@ class TestLiterals:
(cg.FloatLiteral(4.2), "4.2f"),
(cg.FloatLiteral(1.23456789), "1.23456789f"),
(cg.FloatLiteral(math.nan), "NAN"),
(cg.FloatLiteral(math.inf), "INFINITY"),
(cg.FloatLiteral(-math.inf), "-INFINITY"),
(cg.FloatLiteral(float("1e400")), "INFINITY"),
(cg.FlashStringLiteral("hello"), 'ESPHOME_F("hello")'),
(cg.FlashStringLiteral(""), 'ESPHOME_F("")'),
(
@@ -819,7 +822,8 @@ class TestSharedProgmemArray:
c = cg.shared_progmem_array("table", ct.uint8, [4])
assert a is b
assert str(a) != str(c)
assert sum("PROGMEM" in str(st) for st in CORE.global_statements) == 2
late = CORE.global_statements_after_includes
assert sum("PROGMEM" in str(st) for st in late) == 2
assert not any("PROGMEM" in str(st) for st in CORE.main_statements)
def test_share_false_always_emits_a_new_array(self) -> None:
@@ -827,7 +831,8 @@ class TestSharedProgmemArray:
a = cg.shared_progmem_array("table", ct.uint8, [1], share=False)
b = cg.shared_progmem_array("table", ct.uint8, [1], share=False)
assert str(a) != str(b)
assert sum("PROGMEM" in str(st) for st in CORE.global_statements) == 2
late = CORE.global_statements_after_includes
assert sum("PROGMEM" in str(st) for st in late) == 2
def test_same_contents_different_type_are_separate(self) -> None:
CORE.config = {}
+9 -3
View File
@@ -215,8 +215,9 @@ def test_convert_pio_libs_arduino_framework_passes_empty_managed(
path=tmp_path / "other-lib",
)
def fake_generate_idf_components(libraries, managed=None):
def fake_generate_idf_components(libraries, managed=None, provides=None):
captured["managed"] = managed
captured["provides"] = provides
return [converted]
monkeypatch.setattr(
@@ -226,6 +227,8 @@ def test_convert_pio_libs_arduino_framework_passes_empty_managed(
result = _convert_pio_libs(tmp_path / "platformio.ini", "arduino")
assert captured["managed"] == set()
# Bundled Arduino names are left to arduino-esp32, as in the real build
assert captured["provides"]("Wire") and not captured["provides"]("Adafruit BusIO")
assert result == {
"esphome/other-lib": {"override_path": str(tmp_path / "other-lib")}
}
@@ -242,7 +245,7 @@ def test_convert_pio_libs_arduino_idf_6_passes_manifest_deps(
captured: dict[str, set[str] | None] = {}
def fake_generate_idf_components(libraries, managed=None):
def fake_generate_idf_components(libraries, managed=None, provides=None):
captured["managed"] = managed
return []
@@ -265,8 +268,9 @@ def test_convert_pio_libs_espidf_framework_passes_manifest_deps(
captured: dict[str, set[str] | None] = {}
def fake_generate_idf_components(libraries, managed=None):
def fake_generate_idf_components(libraries, managed=None, provides=None):
captured["managed"] = managed
captured["provides"] = provides
return []
monkeypatch.setattr(
@@ -276,6 +280,8 @@ def test_convert_pio_libs_espidf_framework_passes_manifest_deps(
result = _convert_pio_libs(tmp_path / "platformio.ini", "espidf")
assert captured["managed"] == _esphome_manifest_deps()
# ESP-IDF bundles no Arduino libraries
assert captured["provides"] is None
assert "esphome/noise-c" in captured["managed"]
assert result == {}
+656 -122
View File
@@ -4,11 +4,13 @@ Covers the shared download/parse/resolve/dependency-walk paths in
``esphome.platformio.library`` directly (the ESP-IDF and Zephyr backends are
exercised in their own test modules)."""
from collections.abc import Collection
import json
import logging
from pathlib import Path
from unittest.mock import Mock
from platformio.package.exception import UnknownPackageError
import pytest
from esphome.core import CORE, EsphomeError, Library
@@ -261,23 +263,37 @@ def test_make_registry_client_creates_http_cache_dir(
lib._make_registry_client()
def _patch_registry_resolve(monkeypatch: pytest.MonkeyPatch) -> None:
"""Stub the registry lookup so tests never touch the network."""
monkeypatch.setattr(
lib,
"_resolve_registry_version",
lambda owner, pkgname, requirements: (
owner,
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
def _patch_registry_resolve(
monkeypatch: pytest.MonkeyPatch,
*,
unknown: Collection[str] = (),
owner: str | None = None,
) -> list[tuple[str | None, str]]:
"""Stub the registry lookup so tests never touch the network; returns the
(owner, name) pairs looked up. ``unknown`` names miss; ``owner`` is what the
registry answers for an owner-less name."""
asked: list[tuple[str | None, str]] = []
def resolve(requested_owner, pkgname, requirements):
asked.append((requested_owner, pkgname))
if pkgname in unknown:
raise UnknownPackageError(pkgname)
return _resolution(requested_owner or owner, pkgname)
monkeypatch.setattr(lib, "_resolve_registry_version", resolve)
return asked
def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()):
"""Fake ConvertedLibrary.download to materialize canned manifests on disk."""
def _resolution(owner, pkgname):
"""What the stubbed registry answers for a package."""
return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz", None
def _patch_download_with_manifests(
monkeypatch, tmp_path, manifests, *, properties=(), unknown=(), owner=None
):
"""Fake ConvertedLibrary.download to materialize canned manifests on disk;
returns the registry lookups made (see _patch_registry_resolve)."""
def fake_download(self, force=False, salt="", namespace=""):
self.path = tmp_path / self.get_require_name()
@@ -288,7 +304,10 @@ def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properti
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
monkeypatch.setattr(ConvertedLibrary, "download", fake_download)
_patch_registry_resolve(monkeypatch)
# The wave prefetch would clone git sources for real; the walk's own
# download() above is what these tests exercise
monkeypatch.setattr(lib, "_prefetch_wave", lambda *args: None)
return _patch_registry_resolve(monkeypatch, unknown=unknown, owner=owner)
def test_wave_requirement_growth_defers_the_superseded_download(tmp_path, monkeypatch):
@@ -343,18 +362,22 @@ def test_convert_libraries_parses_library_properties(tmp_path, monkeypatch):
assert emitted[0].data["version"] == "1.0"
def test_convert_libraries_skips_dependency_without_version(tmp_path, monkeypatch):
# A dependency entry lacking a version is malformed and silently skipped.
_patch_download_with_manifests(
def test_convert_libraries_fetches_dependency_without_version(tmp_path, monkeypatch):
# A dependency entry lacking a version resolves by name alone, as PlatformIO did.
asked = _patch_download_with_manifests(
monkeypatch,
tmp_path,
{"esphome/A": {"name": "A", "dependencies": [{"name": "C"}]}},
{
"esphome/A": {"name": "A", "dependencies": [{"name": "C"}]},
"C": {"name": "C"},
},
)
# No version on the top-level spec exercises the "no requirement" path too.
top = convert_libraries([Library("esphome/A", None, None)], _backend())
assert top[0].dependencies == []
assert [d.name for d in top[0].dependencies] == ["C"]
assert asked == [("esphome", "A"), (None, "C")]
def test_convert_libraries_handles_unparsable_dependency_version(tmp_path, monkeypatch):
@@ -1013,20 +1036,24 @@ def test_convert_libraries_malformed_transitive_dep_skips(
assert "Skipping dependency esphome/B: malformed manifest" in caplog.text
def test_walk_warns_for_properties_only_depends(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A manifest declaring dependencies only as library.properties depends=
warns in the shared walk, so every backend reports the drop."""
_patch_download_with_manifests(
def test_walk_resolves_properties_only_depends(tmp_path, monkeypatch) -> None:
"""A library.properties depends= line is a dependency list: a bundled name
goes to the backend, the rest resolve by name."""
asked = _patch_download_with_manifests(
monkeypatch,
tmp_path,
{"esphome/A": "name=A\nversion=1.0\ndepends=Wire, SPI\n"},
{
"esphome/A": "name=A\nversion=1.0\ndepends=Wire, SPI\n",
"SPI": {"name": "SPI"},
},
properties=("esphome/A",),
)
caplog.set_level("INFO")
convert_libraries([Library("esphome/A", "1.0.0", None)], _backend())
assert "declares dependencies via library.properties" in caplog.text
backend = _backend(provides=lambda name: name == "Wire")
top = convert_libraries([Library("esphome/A", "1.0.0", None)], backend)
assert [d.name for d in top[0].dependencies] == ["SPI"]
assert "depends" not in top[0].data
assert backend.provided_requests == {"Wire"}
assert (None, "SPI") in asked
def test_walk_warns_for_nonplatform_invalid_library(
@@ -1093,12 +1120,12 @@ def test_source_kind_map_shape() -> None:
assert SOURCE_KIND_FOR_SUFFIX[".C++"] == "cxx"
def test_versionless_platform_filtered_dependency_stays_quiet(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
def test_versionless_platform_filtered_dependency_is_not_fetched(
tmp_path, monkeypatch
) -> None:
"""A version-less dependency the platform filter excludes is
deliberately absent, not a drop to warn about."""
_patch_download_with_manifests(
"""A version-less dependency the platform filter excludes is deliberately
absent, not fetched by name."""
asked = _patch_download_with_manifests(
monkeypatch,
tmp_path,
{
@@ -1108,59 +1135,52 @@ def test_versionless_platform_filtered_dependency_stays_quiet(
}
},
)
convert_libraries([Library("esphome/A", None, None)], _backend())
assert "has no version to resolve" not in caplog.text
top = convert_libraries([Library("esphome/A", None, None)], _backend())
assert top[0].dependencies == []
assert (None, "Hash") not in asked
def test_versionless_ignored_dependency_stays_quiet(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
def test_versionless_ignored_dependency_is_not_fetched(tmp_path, monkeypatch) -> None:
"""A lib_ignore'd version-less dependency is deliberately excluded, not
a drop; no reconciliation warning."""
_patch_download_with_manifests(
fetched by name."""
asked = _patch_download_with_manifests(
monkeypatch,
tmp_path,
{"esphome/A": {"name": "A", "dependencies": [{"name": "Hash"}]}},
)
CORE.platformio_options = {"lib_ignore": ["Hash"]}
convert_libraries([Library("esphome/A", None, None)], _backend())
assert "has no version to resolve" not in caplog.text
top = convert_libraries([Library("esphome/A", None, None)], _backend())
assert top[0].dependencies == []
assert (None, "Hash") not in asked
def test_versionless_dependency_without_provider_warns(
def test_versionless_dependency_registry_miss_is_skipped(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A backend whose tree could supply the name warns on the drop; one
without provides() can never act on it, so it stays at debug."""
"""A name-only dependency the registry does not know is skipped with one
warning naming the requester, and the requester still converts; a listed
library the registry does not know still fails the build."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {
"name": "A",
# The duplicate entry warns once (reconciliation dedup)
# The duplicate entry is one node, so it warns once
"dependencies": [{"name": "Hash"}, {"name": "Hash"}],
}
},
unknown={"Hash"},
)
convert_libraries(
[Library("esphome/A", None, None)], _backend(provides=lambda name: False)
)
top = convert_libraries([Library("esphome/A", None, None)], _backend())
assert top[0].dependencies == []
assert (
caplog.text.count(
"Hash of esphome/A has no version to resolve and nothing provides it"
)
caplog.text.count("Dependency Hash of esphome/A was not resolved; skipping")
== 1
)
caplog.clear()
with caplog.at_level(logging.DEBUG):
convert_libraries([Library("esphome/A", None, None)], _backend())
records = [
r
for r in caplog.records
if "has no version to resolve and nothing provides it" in r.message
]
assert records and all(r.levelno == logging.DEBUG for r in records)
with pytest.raises(UnknownPackageError):
convert_libraries([Library("Hash", None, None)], _backend())
def test_url_version_dependency_is_not_substituted_by_provides(
@@ -1191,102 +1211,616 @@ def test_url_version_dependency_is_not_substituted_by_provides(
assert any("o/hash" in n.lower() for n in emitted)
def test_versionless_owner_qualified_dependency_warns_despite_provides(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
def test_versionless_owner_qualified_dependency_is_fetched_despite_provides(
tmp_path, monkeypatch
) -> None:
"""An owner-qualified version-less dependency is not satisfied by
provides(); it must still warn."""
_patch_download_with_manifests(
"""An owner-qualified version-less dependency names a registry package,
so provides() does not stand in for it."""
asked = _patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {
"name": "A",
"dependencies": [{"name": "Wire", "owner": "Foo"}],
}
},
"Foo/Wire": {"name": "Wire"},
},
)
convert_libraries(
[Library("esphome/A", None, None)],
_backend(provides=lambda name: name == "Wire"),
)
assert "Wire of esphome/A has no version to resolve" in caplog.text
def test_versionless_provided_dependency_stays_quiet(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""An owner-less version-less dependency the backend provides is added
by the backend after emit; no reconciliation warning."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{"esphome/A": {"name": "A", "dependencies": [{"name": "Wire"}]}},
)
convert_libraries(
[Library("esphome/A", None, None)],
_backend(provides=lambda name: name == "Wire"),
)
assert "has no version to resolve" not in caplog.text
def test_versionless_dependency_requested_top_level_stays_quiet(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A version-less dependency the config also requests top-level is in
the build; no drop warning even without a provides backend."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {"name": "A", "dependencies": [{"name": "Hash"}]},
"Hash": {"name": "Hash"},
},
)
convert_libraries(
[Library("esphome/A", None, None), Library("Hash", None, None)],
_backend(),
)
assert "has no version to resolve" not in caplog.text
backend = _backend(provides=lambda name: name == "Wire")
top = convert_libraries([Library("esphome/A", None, None)], backend)
assert [d.name for d in top[0].dependencies] == ["Foo/Wire"]
assert ("Foo", "Wire") in asked
assert not backend.provided_requests
def test_versionless_url_ish_dependency_name_warns_cleanly(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A malformed URL-ish dependency name falls to the drop warning, never
"""A malformed URL-ish dependency name is skipped with a warning, never
a RuntimeError out of the key parser."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{"esphome/A": {"name": "A", "dependencies": [{"name": "file://"}]}},
)
convert_libraries(
[Library("esphome/A", None, None)], _backend(provides=lambda name: False)
top = convert_libraries([Library("esphome/A", None, None)], _backend())
assert top[0].dependencies == []
assert "Skipping dependency file:// of esphome/A" in caplog.text
def test_versionless_dependency_matching_resolved_manifest_name_is_wired(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A bare name no package name matches is satisfied by a listed component's
manifest name (a fork published under another package name)."""
asked = _patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
"esphome/B-fork": {"name": "B"},
},
)
with caplog.at_level(logging.INFO):
top = convert_libraries(
[Library("esphome/A", None, None), Library("esphome/B-fork", None, None)],
_backend(),
)
assert [d.name for d in top[0].dependencies] == ["esphome/B-fork"]
assert asked == [("esphome", "A"), ("esphome", "B-fork")]
assert (
"file:// of esphome/A has no version to resolve and nothing provides it"
"Dependency B of esphome/A satisfied by esphome/B-fork through its manifest name"
in caplog.text
)
def test_versionless_dependency_matching_resolved_manifest_name_stays_quiet(
def test_dependency_naming_the_requester_itself_is_ignored(
tmp_path, monkeypatch
) -> None:
"""A manifest that lists its own name as a dependency neither fetches a
second copy of itself nor gains a self edge."""
asked = _patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": "name=A\ndepends=a, esphome/A\n",
# No manifest name: the requested and resolved package names still
# identify it, with or without the owner
"esphome/C": {
"dependencies": [{"name": "C"}, {"name": "C", "owner": "esphome"}]
},
},
properties=("esphome/A",),
)
top = convert_libraries(
[Library("esphome/A", "1.0.0", None), Library("esphome/C", "1.0.0", None)],
_backend(),
)
assert [c.dependencies for c in top] == [[], []]
assert asked == [("esphome", "A"), ("esphome", "C")]
def test_versionless_dependency_settles_after_the_graph(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A bare name satisfied by an owner-qualified component's manifest
name is not a drop."""
"""The issue 21308 shape: a library.properties depends= naming a library the
config lists owner-qualified is wired to that one package, case-insensitively
and whatever constraint it carries, without a second node for the bare name
and ahead of a same-named bundled copy, as on the Arduino backend."""
asked = _patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"adafruit/Adafruit GFX Library": (
"name=Adafruit GFX Library\ndepends=adafruit busio (>=1.0)\n"
),
"adafruit/Adafruit BusIO": {"name": "Adafruit BusIO"},
},
properties=("adafruit/Adafruit GFX Library",),
)
backend = _backend(provides=lambda name: name == "adafruit busio")
top = convert_libraries(
[
Library("adafruit/Adafruit GFX Library", "^1.13.0", None),
Library("adafruit/Adafruit BusIO", "^1.17.4", None),
],
backend,
)
assert [d.name for d in top[0].dependencies] == ["adafruit/Adafruit BusIO"]
assert len(asked) == 2
assert not backend.provided_requests
assert "multiple names" not in caplog.text
def test_by_name_node_folds_into_a_later_pin_of_the_same_package(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A library fetched by name alone may pull in a manifest that pins the same
package owner-qualified; the by-name node folds into that one instead of
converting the package twice."""
# Manifests are keyed by the resolved name, so both B nodes read the same one:
# B pins C, and C pins owner/B, which arrives after B was fetched by name
manifests = {
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
"esphome/B": {"name": "B", "dependencies": {"esphome/C": "1.0.0"}},
"esphome/C": {"name": "C", "dependencies": {"esphome/B": "1.0.0"}},
}
_patch_download_with_manifests(monkeypatch, tmp_path, manifests, owner="esphome")
emitted: list[str] = []
top = convert_libraries(
[Library("esphome/A", None, None)],
_backend(emit=lambda c: emitted.append(c.name)),
)
assert [d.name for d in top[0].dependencies] == ["esphome/B"]
assert emitted.count("esphome/B") == 1
assert "multiple names" not in caplog.text
def test_two_spellings_of_a_name_in_one_round_share_a_node(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Requesters spelling one unlisted name differently get a single by-name
fetch and the same node."""
asked = _patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {"name": "A", "dependencies": [{"name": "Foo"}]},
"esphome/B": {"name": "B", "dependencies": [{"name": "foo"}]},
"Foo": {"name": "Foo"},
},
)
top = convert_libraries(
[Library("esphome/A", None, None), Library("esphome/B", None, None)],
_backend(),
)
assert [d.name for d in top[0].dependencies] == ["Foo"]
assert [d.name for d in top[1].dependencies] == ["Foo"]
assert asked.count((None, "Foo")) + asked.count((None, "foo")) == 1
assert "multiple names" not in caplog.text
def test_parse_properties_depends_reads_names_and_drops_constraints(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Arduino constraints are not PlatformIO syntax, so only the names survive;
an unclosed parenthesis loses nothing more than the constraint."""
with caplog.at_level(logging.DEBUG):
entries = lib.parse_properties_depends(
"Adafruit BusIO, Foo (>=1.2), ,Bar( <2 ), Baz (=1.0, Qux ()"
)
levels = {
r.message.removeprefix("Ignoring version constraint on depends="): r.levelno
for r in caplog.records
if "Ignoring version constraint" in r.message
}
# A lower bound is met by the current version; a cap or pin may not be
assert levels == {
"Foo (>=1.2)": logging.DEBUG,
"Bar( <2 )": logging.INFO,
"Baz (=1.0": logging.INFO,
"Qux ()": logging.INFO,
}
assert entries == [
{"name": "Adafruit BusIO"},
{"name": "Foo"},
{"name": "Bar"},
{"name": "Baz"},
{"name": "Qux"},
]
assert "Ignoring version constraint on depends=Foo (>=1.2)" in caplog.text
def test_by_name_node_pinned_later_reports_a_requirement_conflict(
tmp_path, monkeypatch
) -> None:
"""A node fetched by name that a later manifest pins under the same spelling
is resolved again against the pin; a conflict is an error, not a miss."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
"esphome/B": {"name": "B"},
"B": {"name": "B", "dependencies": {"C": "1.0.0"}},
"C": {"name": "C", "dependencies": {"B": "^2.0"}},
},
)
def resolve(owner, pkgname, requirements):
if pkgname == "B" and requirements:
raise RuntimeError(f"No version of {pkgname} satisfies {requirements}")
return _resolution(owner, pkgname)
monkeypatch.setattr(lib, "_resolve_registry_version", resolve)
with pytest.raises(RuntimeError, match="No version of B satisfies"):
convert_libraries([Library("esphome/A", None, None)], _backend())
def test_by_name_fetch_yields_to_a_fork_pulled_in_later(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A name fetched from the registry yields to a library another round
pins explicitly under the same manifest name, so only the fork is built."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
"esphome/X": {"name": "X", "dependencies": [{"name": "C"}]},
# The upstream B pulls in D, which nothing else needs; D asks for a
# name the registry does not know
"B": {"name": "B", "dependencies": {"esphome/D": "1.0.0"}},
"esphome/D": {"name": "D", "dependencies": [{"name": "Zed"}]},
"C": {"name": "C", "dependencies": {"custom/B-fork": "1.0.0"}},
"custom/B-fork": {"name": "B"},
},
unknown={"Zed"},
)
emitted: list[str] = []
top = convert_libraries(
[Library("esphome/A", None, None), Library("esphome/X", None, None)],
_backend(emit=lambda c: emitted.append(c.name)),
)
assert [d.name for d in top[0].dependencies] == ["custom/B-fork"]
assert sorted(emitted) == ["C", "custom/B-fork", "esphome/A", "esphome/X"]
# The miss belongs to a library that is not built, so it is not reported
assert "Zed" not in caplog.text
def test_git_source_replacing_a_registry_key_in_the_same_wave(
tmp_path, monkeypatch
) -> None:
"""A manifest in one wave naming a key of the same wave as a git source
defers that key to the next wave instead of tripping over its stale record."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"o/A": {"name": "A", "dependencies": {"B": "https://github.com/o/B.git"}},
"o/B": {"name": "B"},
},
)
emitted: dict[str, ConvertedLibrary] = {}
top = convert_libraries(
[Library("o/A", "1.0.0", None), Library("o/B", "1.0.0", None)],
_backend(emit=lambda c: emitted.__setitem__(c.name, c)),
)
assert [c.name for c in top] == ["o/A", "o/B"]
assert isinstance(emitted["o/B"].source, GitSource)
assert [d.name for d in top[0].dependencies] == ["o/B"]
def test_owner_qualified_name_matches_the_resolved_owner(tmp_path, monkeypatch) -> None:
"""A version-less ``o/B`` is satisfied by a bare ``B`` pin the registry
resolved to owner ``o``, so the package is not fetched a second time."""
asked = _patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {
"name": "A",
"dependencies": [
{"name": "B", "version": "1.0.0"},
{"name": "B", "owner": "o"},
],
},
"o/B": {"name": "B"},
},
owner="o",
)
top = convert_libraries([Library("esphome/A", None, None)], _backend())
assert [d.name for d in top[0].dependencies] == ["o/B"]
assert asked.count((None, "B")) == 1
assert ("o", "B") not in asked
def test_two_spellings_fetched_by_name_fold_into_one_node(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Version-less ``B`` and ``o/B`` from two requesters resolve to one
package; only one copy is built and both requesters depend on it."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
"esphome/X": {"name": "X", "dependencies": [{"name": "B", "owner": "o"}]},
"o/B": {"name": "B"},
},
owner="o",
)
emitted: list[str] = []
top = convert_libraries(
[Library("esphome/A", None, None), Library("esphome/X", None, None)],
_backend(emit=lambda c: emitted.append(c.name)),
)
assert emitted.count("o/B") == 1
kept = top[0].dependencies[0]
assert top[1].dependencies == [kept]
assert "multiple names" not in caplog.text
def test_registry_miss_resolves_again_as_a_git_source(tmp_path, monkeypatch) -> None:
"""A key the registry missed is resolved again when a later manifest names
the same key as a git source."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {"name": "A", "dependencies": [{"name": "B", "owner": "o"}]},
"esphome/X": {"name": "X", "dependencies": [{"name": "C"}]},
"C": {"name": "C", "dependencies": {"B": "https://github.com/o/B.git"}},
"o/B": {"name": "B"},
},
unknown={"B"},
)
emitted: dict[str, ConvertedLibrary] = {}
top = convert_libraries(
[Library("esphome/A", None, None), Library("esphome/X", None, None)],
_backend(emit=lambda c: emitted.__setitem__(c.name, c)),
)
assert [d.name for d in top[0].dependencies] == ["o/B"]
assert isinstance(emitted["o/B"].source, GitSource)
assert [d.name for d in emitted["C"].dependencies] == ["o/B"]
def test_normalize_dependencies_splits_an_embedded_owner() -> None:
"""``owner/Name`` as one string, in a list or a dict entry, normalizes like
the dict form; a URL or a deeper path is left alone."""
assert lib.normalize_dependencies(
["o/B", {"name": "o/C", "version": "1.0"}, "x://y", "a/b/c", "../escape"],
"libx",
) == [
{"name": "B", "owner": "o"},
{"name": "C", "owner": "o", "version": "1.0"},
{"name": "x://y"},
{"name": "a/b/c"},
{"name": "../escape"},
]
assert lib.normalize_dependencies("o/B, D", "libx") == [
{"name": "B", "owner": "o"},
{"name": "D"},
]
def test_owner_qualified_string_dependency_is_fetched_and_wired(
tmp_path, monkeypatch
) -> None:
"""``"dependencies": ["o/B"]`` fetches ``o/B`` and wires the requester to it."""
asked = _patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {"name": "A", "dependencies": ["o/B"]},
"o/B": {"name": "B"},
},
)
top = convert_libraries([Library("esphome/A", None, None)], _backend())
assert [d.name for d in top[0].dependencies] == ["o/B"]
assert asked == [("esphome", "A"), ("o", "B")]
def test_versionless_url_dependency_is_wired_to_its_source(
tmp_path, monkeypatch
) -> None:
"""A dependency written as a git or ``file://`` URL goes by the key its
source gave it: the requester is wired to it and it is built."""
asked = _patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {
"name": "A",
"dependencies": [
"https://github.com/vendor/B.git",
(tmp_path / "local" / "C").as_uri(),
],
},
"vendor/B": {"name": "B"},
"C": {"name": "C"},
},
)
emitted: dict[str, ConvertedLibrary] = {}
top = convert_libraries(
[Library("esphome/A", None, None)],
_backend(emit=lambda c: emitted.__setitem__(c.name, c)),
)
assert [d.name for d in top[0].dependencies] == ["C", "vendor/B"]
assert isinstance(emitted["vendor/B"].source, GitSource)
assert isinstance(emitted["C"].source, LocalSource)
assert asked == [("esphome", "A")]
def test_url_dependencies_differing_only_in_case_stay_distinct(
tmp_path, monkeypatch
) -> None:
"""Two local sources whose paths differ only in case are both fetched and
each requester is wired to its own; only registry names fold case."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {
"name": "A",
"dependencies": [(tmp_path / "local" / "Upper").as_uri()],
},
"esphome/X": {
"name": "X",
"dependencies": [(tmp_path / "local" / "upper").as_uri()],
},
"Upper": {"name": "Lib"},
"upper": {"name": "Lib"},
},
)
emitted: dict[str, ConvertedLibrary] = {}
top = convert_libraries(
[Library("esphome/A", None, None), Library("esphome/X", None, None)],
_backend(emit=lambda c: emitted.__setitem__(c.name, c)),
)
assert [d.name for d in top[0].dependencies] == ["Upper"]
assert [d.name for d in top[1].dependencies] == ["upper"]
assert all(
isinstance(emitted[key].source, LocalSource) for key in ("Upper", "upper")
)
def test_listed_git_source_satisfies_a_bare_dependency_by_repository_name(
tmp_path, monkeypatch
) -> None:
"""A git source goes by its repository name: a bare ``B`` is satisfied by
the listed ``vendor/B`` even when its manifest says otherwise, so the
registry is never asked for a second copy."""
asked = _patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {"name": "A", "dependencies": ["B"]},
"vendor/B": {"name": "Other"},
},
)
emitted: list[str] = []
top = convert_libraries(
[
Library("esphome/A", None, None),
Library("https://github.com/vendor/B.git", None, None),
],
_backend(emit=lambda c: emitted.append(c.name)),
)
assert [d.name for d in top[0].dependencies] == ["vendor/B"]
assert sorted(emitted) == ["esphome/A", "vendor/B"]
assert asked == [("esphome", "A")]
def test_empty_version_is_settled_like_a_missing_one(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""``{"Foo": ""}`` carries no version either, so it is matched, fetched by
name or reported like a version-less entry rather than failing or vanishing."""
asked = _patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {"name": "A", "dependencies": {"Foo": "", "Bar": ""}},
"Bar": {"name": "Bar"},
},
unknown={"Foo"},
)
top = convert_libraries([Library("esphome/A", None, None)], _backend())
assert [d.name for d in top[0].dependencies] == ["Bar"]
assert (None, "Foo") in asked
assert "Dependency Foo of esphome/A was not resolved; skipping" in caplog.text
def test_bundled_name_is_satisfied_by_a_replacement_pulled_in_later(
tmp_path, monkeypatch
) -> None:
"""A name the framework bundles is still wired to an explicit replacement
of the same manifest name that a later round pulls in, as it would be had
the replacement been listed up front."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {"name": "A", "dependencies": [{"name": "Ethernet"}]},
"esphome/X": {"name": "X", "dependencies": [{"name": "C"}]},
"C": {"name": "C", "dependencies": {"vendor/Ethernet": "1.0.0"}},
"vendor/Ethernet": {"name": "Ethernet"},
},
)
backend = _backend(provides=lambda name: name == "Ethernet")
top = convert_libraries(
[Library("esphome/A", None, None), Library("esphome/X", None, None)],
backend,
)
assert [d.name for d in top[0].dependencies] == ["vendor/Ethernet"]
def test_missed_name_is_satisfied_by_a_library_pulled_in_later(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A bare name the registry does not know is wired to a library a later
round pulls in under another package name but the same manifest name."""
asked = _patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
"esphome/X": {"name": "X", "dependencies": [{"name": "C"}]},
# Fetched by name in the same round as the miss; pins the fork
"C": {"name": "C", "dependencies": {"owner/B-fork": "1.0.0"}},
"owner/B-fork": {"name": "B"},
},
unknown={"B"},
)
with caplog.at_level(logging.INFO):
top = convert_libraries(
[Library("esphome/A", None, None), Library("esphome/X", None, None)],
_backend(),
)
assert [d.name for d in top[0].dependencies] == ["owner/B-fork"]
assert asked.count((None, "B")) == 1
assert (
"Dependency B of esphome/A satisfied by owner/B-fork through its manifest name"
in caplog.text
)
assert "skipping" not in caplog.text
def test_by_name_request_does_not_make_an_existing_node_optional(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A version-less request for a node the walk already created (here one
skipped for its platform) neither re-fetches it nor turns a later miss
into a warning."""
asked = _patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {
"name": "A",
"dependencies": [{"name": "X", "version": "1.0"}, {"name": "X"}],
},
"X": {"name": "X", "platforms": "espressif8266"},
},
)
top = convert_libraries([Library("esphome/A", None, None)], _backend())
assert top[0].dependencies == []
assert asked == [("esphome", "A"), (None, "X")]
assert "not in the registry" not in caplog.text
def test_provided_dependency_asks_the_backend_once_per_name(
tmp_path, monkeypatch
) -> None:
"""A bundled name is recorded for the backend and never fetched; two
requesters cost one provides() call, so a backend warning there is not
repeated. A pinned request on the same name shares the memo."""
asked = _patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {"name": "A", "dependencies": [{"name": "Wire"}]},
"esphome/B": {"name": "B", "dependencies": {"Wire": "*"}},
},
)
calls: list[str] = []
def provides(name: str) -> bool:
calls.append(name)
return name == "Wire"
backend = _backend(provides=provides)
convert_libraries(
[Library("esphome/A", None, None), Library("esphome/B", None, None)],
_backend(),
backend,
)
assert "has no version to resolve" not in caplog.text
assert calls == ["Wire"]
assert backend.provided_requests == {"Wire"}
assert (None, "Wire") not in asked
def test_convert_libraries_symlink_url_resolves_as_local(setup_core: Path) -> None:
+19
View File
@@ -467,6 +467,25 @@ def test_redirect_text_keeps_output_after_an_exotic_break_character(
assert buf.getvalue() == f"first{break_char}second\nthird\n"
def test_redirect_text_keeps_the_color_codes_of_a_filtered_line() -> None:
"""A dropped line still delivers its color codes.
With ``CLICOLOR_FORCE`` cmake prints a warning in yellow and puts the
reset on a line of its own after a blank line. The IDF build filters
blank lines, matched without ANSI codes, so the reset used to be dropped
and the output after the warning stayed yellow.
"""
redirect, buf = _make_redirect(filter_lines=[r"\s*$"])
redirect.write(
"\x1b[33mCMake Warning at a.cmake:3 (message):\n w\n\n\x1b[0m\n-- b\n"
)
assert buf.getvalue() == (
"\x1b[33mCMake Warning at a.cmake:3 (message):\n w\n\x1b[0m-- b\n"
)
def test_redirect_text_treats_crlf_as_one_terminator() -> None:
r"""``\r\n``, a lone ``\r`` and a lone ``\n`` each end exactly one line."""
redirect, buf = _make_redirect(filter_lines=["ignore me"])