Merge branch 'esp8266-native-library-backend' into esp8266-native-build-spec

This commit is contained in:
J. Nick Koston
2026-08-22 14:52:40 -05:00
5 changed files with 103 additions and 64 deletions
+30 -20
View File
@@ -24,7 +24,7 @@ from esphome.platformio.extra_script import apply_extra_script
from esphome.platformio.library import ( from esphome.platformio.library import (
DEFAULT_BUILD_INCLUDE_DIR, DEFAULT_BUILD_INCLUDE_DIR,
DEFAULT_BUILD_SRC_FILTER, DEFAULT_BUILD_SRC_FILTER,
HEADER_FILE_EXTENSIONS, LIBRARY_HEADER_SUFFIXES,
SRC_FILE_EXTENSIONS, SRC_FILE_EXTENSIONS,
ConvertedLibrary, ConvertedLibrary,
InvalidLibrary, InvalidLibrary,
@@ -219,17 +219,18 @@ def _collect_lib_sources(
len(dropped), len(dropped),
", ".join(sorted(dropped)), ", ".join(sorted(dropped)),
) )
if ( if not lib.sources and not any(
not lib.sources Path(f).suffix.lower() in LIBRARY_HEADER_SUFFIXES for f in matched
and ("srcFilter" in build or "srcDir" in build)
and not any(Path(f).suffix.lower() in HEADER_FILE_EXTENSIONS for f in matched)
): ):
# A declared filter matching nothing (or only inert files) is a # Matched headers mean a header-only library; anything else with no
# manifest/tree problem; matched headers mean a header-only library # sources yields an empty archive that fails far away at link
_LOGGER.warning( if "srcFilter" in build or "srcDir" in build:
"Library %s declares srcFilter/srcDir but no source files matched", _LOGGER.warning(
name, "Library %s declares srcFilter/srcDir but no source files matched",
) name,
)
else:
_LOGGER.warning("Library %s has no sources or headers", name)
def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary: def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary:
@@ -284,7 +285,7 @@ def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary:
) )
lib = _library_info(name, lib_dir, data) lib = _library_info(name, lib_dir, data)
if not lib.sources and not any( if not lib.sources and not any(
Path(p).suffix.lower() in HEADER_FILE_EXTENSIONS for p in walk_files(lib_dir) Path(p).suffix.lower() in LIBRARY_HEADER_SUFFIXES for p in walk_files(lib_dir)
): ):
# An empty or half-extracted bundled directory can never link; a # An empty or half-extracted bundled directory can never link; a
# warning would scroll away and resurface as undefined symbols # warning would scroll away and resurface as undefined symbols
@@ -390,14 +391,23 @@ def resolve_libraries(
) )
continue continue
if name in external_short_names: if name in external_short_names:
# Deliberate when the external really is this library; if _provided(name):
# attributable when the short-name match is accidental # A bundled copy really is suppressed; an accidental
_LOGGER.debug( # short-name collision would surface as link errors
"Dependency %s of %s assumed satisfied by a requested " _LOGGER.warning(
"external library", "Dependency %s of %s is assumed satisfied by a "
name, "requested external library; the bundled copy is "
component.name, "not added",
) name,
component.name,
)
else:
_LOGGER.debug(
"Dependency %s of %s assumed satisfied by a requested "
"external library",
name,
component.name,
)
continue continue
if name in bundled_names or is_lib_ignored(name, lib_ignore): if name in bundled_names or is_lib_ignored(name, lib_ignore):
continue continue
+12 -10
View File
@@ -74,7 +74,7 @@ SOURCE_KIND_FOR_SUFFIX: dict[str, str] = {
SRC_FILE_EXTENSIONS = list(SOURCE_KIND_FOR_SUFFIX) SRC_FILE_EXTENSIONS = list(SOURCE_KIND_FOR_SUFFIX)
# Suffixes that count as headers when probing whether a library has any # Suffixes that count as headers when probing whether a library has any
# usable files at all (compare against Path.suffix.lower()) # usable files at all (compare against Path.suffix.lower())
HEADER_FILE_EXTENSIONS = frozenset( LIBRARY_HEADER_SUFFIXES = frozenset(
{".h", ".hpp", ".hh", ".hxx", ".inc", ".ipp", ".tcc"} {".h", ".hpp", ".hh", ".hxx", ".inc", ".ipp", ".tcc"}
) )
@@ -1124,7 +1124,7 @@ def convert_libraries(
for dependency in normalize_dependencies( for dependency in normalize_dependencies(
component.data.get("dependencies"), component.name component.data.get("dependencies"), component.name
): ):
if "name" not in dependency or "version" not in dependency: if "version" not in dependency:
# Version-less deps cannot resolve from the registry; the # Version-less deps cannot resolve from the registry; the
# post-emit reconciliation owns the drop warning. An # post-emit reconciliation owns the drop warning. An
# is_lib_ignored name is deliberately excluded, not a drop. # is_lib_ignored name is deliberately excluded, not a drop.
@@ -1158,30 +1158,32 @@ def convert_libraries(
if is_lib_ignored(dep_name, lib_ignore): if is_lib_ignored(dep_name, lib_ignore):
_LOGGER.debug("Skip ignored dependency %s", dep_name) _LOGGER.debug("Skip ignored dependency %s", dep_name)
continue continue
if ( # The version field may actually be a URL (git/archive
# dependency), which names one specific source; it must not
# be substituted with a same-named bundled library below.
dep_version = dependency["version"]
dep_url = _url_or_none(dep_version)
if dep_url is not None:
dep_version = None
elif (
backend.provides is not None backend.provides is not None
and not dependency.get("owner") and not dependency.get("owner")
and backend.provides(dep_name) and backend.provides(dep_name)
): ):
# The backend adds it from its own tree; resolving it here # The backend adds it from its own tree; resolving it here
# would fetch a same-named registry package instead # would fetch a same-named registry package instead
if (pin := dependency.get("version")) and pin != "*": if dep_version and dep_version != "*":
# The version pin is discarded for the bundled copy; # The version pin is discarded for the bundled copy;
# make the substitution visible # make the substitution visible
_LOGGER.warning( _LOGGER.warning(
"Dependency %s pins version %s; using the library " "Dependency %s pins version %s; using the library "
"bundled with the framework instead", "bundled with the framework instead",
dep_name, dep_name,
pin, dep_version,
) )
else: else:
_LOGGER.debug("Skip backend-provided dependency %s", dep_name) _LOGGER.debug("Skip backend-provided dependency %s", dep_name)
continue continue
# The version field may actually be a URL (git/archive dependency).
dep_version = dependency["version"]
dep_url = _url_or_none(dep_version)
if dep_url is not None:
dep_version = None
dep_key = add_spec(dep_name, dep_version, dep_url) dep_key = add_spec(dep_name, dep_version, dep_url)
node.edges.add(dep_key) node.edges.add(dep_key)
worklist.append(dep_key) worklist.append(dep_key)
+24 -2
View File
@@ -188,14 +188,16 @@ def test_library_info_declared_filter_matches_nothing_warns(
assert "declares srcFilter/srcDir but no source files matched" in caplog.text assert "declares srcFilter/srcDir but no source files matched" in caplog.text
def test_library_info_header_only_does_not_warn( def test_library_info_empty_tree_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
"""No sources and no headers is an empty archive waiting to fail at
link; warn by name even without a declared filter."""
read_path = tmp_path / "lib" read_path = tmp_path / "lib"
(read_path / "src").mkdir(parents=True) (read_path / "src").mkdir(parents=True)
lib = component._library_info("x", read_path, {}) lib = component._library_info("x", read_path, {})
assert not lib.sources assert not lib.sources
assert "no source files matched" not in caplog.text assert "has no sources or headers" in caplog.text
def test_library_info_no_src_dir(tmp_path: Path) -> None: def test_library_info_no_src_dir(tmp_path: Path) -> None:
@@ -458,6 +460,26 @@ def test_nonplatform_rejection_warns_once_through_real_converter(
assert caplog.text.count("manifest is corrupt") == 1 assert caplog.text.count("manifest is corrupt") == 1
def test_short_name_collision_with_bundled_name_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Suppressing a genuinely bundled name on a short-name match warns;
an accidental collision would otherwise surface at link."""
framework = _make_framework(tmp_path)
_add_library("Someone/Wire", "1.0.0")
converted = _converted(
"someone__Wire",
tmp_path / "conv",
{"build": {}, "dependencies": [{"name": "Wire"}]},
)
(tmp_path / "conv" / "src").mkdir(parents=True)
with _emitting_converter(converted):
libs = _resolve(framework)
assert "Wire" not in [lib.name for lib in libs]
assert "assumed satisfied by a requested external library" in caplog.text
assert any(r.levelname == "WARNING" for r in caplog.records)
def test_missing_libraries_dir_is_a_broken_install(tmp_path: Path) -> None: def test_missing_libraries_dir_is_a_broken_install(tmp_path: Path) -> None:
"""A framework tree without libraries/ must fail by name, not silently """A framework tree without libraries/ must fail by name, not silently
reroute every bundled name to the registry.""" reroute every bundled name to the registry."""
+8 -28
View File
@@ -11,24 +11,28 @@ from pathlib import Path
import re import re
import sys import sys
import time import time
from types import SimpleNamespace
from typing import Any, Self from typing import Any, Self
from unittest.mock import AsyncMock, MagicMock, Mock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest import pytest
from pytest import CaptureFixture from pytest import CaptureFixture
import serial
from zeroconf import ServiceStateChange from zeroconf import ServiceStateChange
from esphome import __main__ as main from esphome import __main__ as main, yaml_util
from esphome.__main__ import ( from esphome.__main__ import (
Purpose, Purpose,
_get_configured_xtal_freq, _get_configured_xtal_freq,
_make_crystal_freq_callback, _make_crystal_freq_callback,
_redact_with_legacy_fallback, _redact_with_legacy_fallback,
_resolve_network_devices, _resolve_network_devices,
_should_subscribe_states,
_split_network_devices, _split_network_devices,
_unresolved_default_error, _unresolved_default_error,
_validate_bootloader_binary, _validate_bootloader_binary,
_validate_partition_table_binary, _validate_partition_table_binary,
_wrap_to_code,
check_permissions, check_permissions,
choose_upload_log_host, choose_upload_log_host,
command_analyze_memory, command_analyze_memory,
@@ -67,19 +71,21 @@ from esphome.__main__ import (
) )
from esphome.address_cache import AddressCache from esphome.address_cache import AddressCache
from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult
from esphome.components import esp32, esp8266 from esphome.components import esp32, esp8266, mqtt
from esphome.components.esp32 import ( from esphome.components.esp32 import (
KEY_ESP32, KEY_ESP32,
KEY_VARIANT, KEY_VARIANT,
VARIANT_ESP32, VARIANT_ESP32,
get_esp32_variant, get_esp32_variant,
) )
from esphome.config import Config
from esphome.const import ( from esphome.const import (
CONF_API, CONF_API,
CONF_AUTH, CONF_AUTH,
CONF_BAUD_RATE, CONF_BAUD_RATE,
CONF_BROKER, CONF_BROKER,
CONF_DISABLED, CONF_DISABLED,
CONF_DISCOVER_IP,
CONF_ESPHOME, CONF_ESPHOME,
CONF_LEVEL, CONF_LEVEL,
CONF_LOG, CONF_LOG,
@@ -565,8 +571,6 @@ def test_command_config__no_defaults_dumps_user_snapshot(
) -> None: ) -> None:
"""``--no-defaults`` dumps ``config.user_config`` instead of the """``--no-defaults`` dumps ``config.user_config`` instead of the
validated config, so schema defaults don't leak into the output.""" validated config, so schema defaults don't leak into the output."""
from esphome.config import Config
setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}})
args = MockArgs() args = MockArgs()
args.show_secrets = True args.show_secrets = True
@@ -619,8 +623,6 @@ def test_command_config__no_defaults_skips_strip_default_ids(
) -> None: ) -> None:
"""When ``--no-defaults`` is set, ``strip_default_ids`` isn't run -- """When ``--no-defaults`` is set, ``strip_default_ids`` isn't run --
the user snapshot is already free of schema-injected IDs.""" the user snapshot is already free of schema-injected IDs."""
from esphome.config import Config
setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}})
args = MockArgs() args = MockArgs()
args.show_secrets = True args.show_secrets = True
@@ -3438,9 +3440,6 @@ def test_get_port_type() -> None:
def test_mqtt_reexports_discover_ip() -> None: def test_mqtt_reexports_discover_ip() -> None:
"""The old import path must keep working for external code.""" """The old import path must keep working for external code."""
from esphome.components import mqtt
from esphome.const import CONF_DISCOVER_IP
assert mqtt.CONF_DISCOVER_IP is CONF_DISCOVER_IP assert mqtt.CONF_DISCOVER_IP is CONF_DISCOVER_IP
@@ -5907,8 +5906,6 @@ class MockSerial:
chunk = self.chunks[self.chunk_index] chunk = self.chunks[self.chunk_index]
if chunk is MOCK_SERIAL_END: if chunk is MOCK_SERIAL_END:
# Sentinel means we're done - simulate port closed # Sentinel means we're done - simulate port closed
import serial
raise serial.SerialException("Port closed") raise serial.SerialException("Port closed")
# Respect the requested size and keep any remaining bytes # Respect the requested size and keep any remaining bytes
if size <= 0: if size <= 0:
@@ -5922,8 +5919,6 @@ class MockSerial:
# Entire chunk consumed; advance to the next one # Entire chunk consumed; advance to the next one
self.chunk_index += 1 self.chunk_index += 1
return data # type: ignore[return-value] return data # type: ignore[return-value]
import serial
raise serial.SerialException("Port closed") raise serial.SerialException("Port closed")
@@ -6782,8 +6777,6 @@ def test_parse_args_argcomplete_only_runs_when_completing() -> None:
def test_should_subscribe_states_default() -> None: def test_should_subscribe_states_default() -> None:
"""Test that states are shown by default when nothing is set.""" """Test that states are shown by default when nothing is set."""
from esphome.__main__ import _should_subscribe_states
args = parse_args(["esphome", "logs", "device.yaml"]) args = parse_args(["esphome", "logs", "device.yaml"])
with patch.dict(os.environ, {}, clear=False): with patch.dict(os.environ, {}, clear=False):
os.environ.pop("ESPHOME_LOG_STATES", None) os.environ.pop("ESPHOME_LOG_STATES", None)
@@ -6792,8 +6785,6 @@ def test_should_subscribe_states_default() -> None:
def test_should_subscribe_states_env_suppresses() -> None: def test_should_subscribe_states_env_suppresses() -> None:
"""Test that ESPHOME_LOG_STATES=false suppresses states by default.""" """Test that ESPHOME_LOG_STATES=false suppresses states by default."""
from esphome.__main__ import _should_subscribe_states
args = parse_args(["esphome", "logs", "device.yaml"]) args = parse_args(["esphome", "logs", "device.yaml"])
with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "false"}): with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "false"}):
assert _should_subscribe_states(args) is False assert _should_subscribe_states(args) is False
@@ -6801,8 +6792,6 @@ def test_should_subscribe_states_env_suppresses() -> None:
def test_should_subscribe_states_env_enables() -> None: def test_should_subscribe_states_env_enables() -> None:
"""Test that ESPHOME_LOG_STATES=true enables states by default.""" """Test that ESPHOME_LOG_STATES=true enables states by default."""
from esphome.__main__ import _should_subscribe_states
args = parse_args(["esphome", "logs", "device.yaml"]) args = parse_args(["esphome", "logs", "device.yaml"])
with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "true"}): with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "true"}):
assert _should_subscribe_states(args) is True assert _should_subscribe_states(args) is True
@@ -6810,8 +6799,6 @@ def test_should_subscribe_states_env_enables() -> None:
def test_should_subscribe_states_flag_overrides_env() -> None: def test_should_subscribe_states_flag_overrides_env() -> None:
"""Test that --states overrides ESPHOME_LOG_STATES=false.""" """Test that --states overrides ESPHOME_LOG_STATES=false."""
from esphome.__main__ import _should_subscribe_states
args = parse_args(["esphome", "logs", "--states", "device.yaml"]) args = parse_args(["esphome", "logs", "--states", "device.yaml"])
with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "false"}): with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "false"}):
assert _should_subscribe_states(args) is True assert _should_subscribe_states(args) is True
@@ -6819,8 +6806,6 @@ def test_should_subscribe_states_flag_overrides_env() -> None:
def test_should_subscribe_states_no_flag_overrides_env() -> None: def test_should_subscribe_states_no_flag_overrides_env() -> None:
"""Test that --no-states overrides ESPHOME_LOG_STATES=true.""" """Test that --no-states overrides ESPHOME_LOG_STATES=true."""
from esphome.__main__ import _should_subscribe_states
args = parse_args(["esphome", "logs", "--no-states", "device.yaml"]) args = parse_args(["esphome", "logs", "--no-states", "device.yaml"])
with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "true"}): with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "true"}):
assert _should_subscribe_states(args) is False assert _should_subscribe_states(args) is False
@@ -7250,11 +7235,6 @@ async def test_wrap_to_code_comment_is_insertion_order_independent() -> None:
"""The config comment dumps with sorted keys: voluptuous fills schema """The config comment dumps with sorted keys: voluptuous fills schema
defaults in set-iteration order, so an unsorted dump would churn defaults in set-iteration order, so an unsorted dump would churn
main.cpp and relink the firmware on every run.""" main.cpp and relink the firmware on every run."""
from types import SimpleNamespace
from esphome import yaml_util
from esphome.__main__ import _wrap_to_code
comments: list[str] = [] comments: list[str] = []
async def to_code(conf): async def to_code(conf):
+29 -4
View File
@@ -814,6 +814,34 @@ def test_versionless_dependency_without_provider_warns(
) )
def test_url_version_dependency_is_not_substituted_by_provides(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A URL-valued version names one specific source; the backend-provided
skip must not replace it with the bundled copy."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {
"name": "A",
"dependencies": [
{"name": "Hash", "version": "https://github.com/o/Hash.git"}
],
},
"o/Hash": {"name": "Hash"},
},
)
emitted: list[str] = []
convert_libraries(
[Library("esphome/A", "1.0.0", None)],
_backend(emit=lambda c: emitted.append(c.name), provides=lambda name: True),
)
assert "Skip backend-provided" not in caplog.text
assert "using the library bundled" not in caplog.text
assert any("o/hash" in n.lower() for n in emitted)
def test_versionless_owner_qualified_dependency_warns_despite_provides( def test_versionless_owner_qualified_dependency_warns_despite_provides(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
@@ -899,10 +927,7 @@ def test_versionless_dependency_matching_resolved_manifest_name_stays_quiet(
monkeypatch, monkeypatch,
tmp_path, tmp_path,
{ {
"esphome/A": { "esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
"name": "A",
"dependencies": [{"name": "B"}, {"version": "1.0"}],
},
"esphome/B": {"name": "B"}, "esphome/B": {"name": "B"},
}, },
) )