mirror of
https://github.com/esphome/esphome.git
synced 2026-10-27 08:22:25 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e8f084741 | ||
|
|
6595df0cab | ||
|
|
73894b2490 | ||
|
|
9bef20d8dc | ||
|
|
0e440500a2 | ||
|
|
badfa84fe0 | ||
|
|
e686ecf76e | ||
|
|
64fb4e06b5 | ||
|
|
cb82c9031a | ||
|
|
ca74ab9873 | ||
|
|
86fc740b65 | ||
|
|
450bb52f65 | ||
|
|
ed3615ec69 | ||
|
|
a5f25ebe2d | ||
|
|
7a4775de3d |
No files matched your search
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
@@ -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
@@ -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]
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {"
|
||||
)
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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 == {}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in new issue
Block a user