mirror of
https://github.com/esphome/esphome.git
synced 2026-06-01 09:25:09 +08:00
[core] Unify skip_external_update and honor it in external_files for faster esphome logs (#16016)
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from esphome import git, loader
|
from esphome import git, loader
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
@@ -17,7 +18,7 @@ from esphome.const import (
|
|||||||
TYPE_GIT,
|
TYPE_GIT,
|
||||||
TYPE_LOCAL,
|
TYPE_LOCAL,
|
||||||
)
|
)
|
||||||
from esphome.core import CORE
|
from esphome.core import CORE, TimePeriodSeconds
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -35,17 +36,15 @@ CONFIG_SCHEMA = cv.ensure_list(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: dict[str, Any]) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _process_git_config(config: dict, refresh, skip_update: bool = False) -> str:
|
def _process_git_config(config: dict[str, Any], refresh: TimePeriodSeconds) -> Path:
|
||||||
# When skip_update is True, use NEVER_REFRESH to prevent updates
|
|
||||||
actual_refresh = git.NEVER_REFRESH if skip_update else refresh
|
|
||||||
repo_dir, _ = git.clone_or_update(
|
repo_dir, _ = git.clone_or_update(
|
||||||
url=config[CONF_URL],
|
url=config[CONF_URL],
|
||||||
ref=config.get(CONF_REF),
|
ref=config.get(CONF_REF),
|
||||||
refresh=actual_refresh,
|
refresh=refresh,
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
username=config.get(CONF_USERNAME),
|
username=config.get(CONF_USERNAME),
|
||||||
password=config.get(CONF_PASSWORD),
|
password=config.get(CONF_PASSWORD),
|
||||||
@@ -72,12 +71,12 @@ def _process_git_config(config: dict, refresh, skip_update: bool = False) -> str
|
|||||||
return components_dir
|
return components_dir
|
||||||
|
|
||||||
|
|
||||||
def _process_single_config(config: dict, skip_update: bool = False):
|
def _process_single_config(config: dict[str, Any]) -> None:
|
||||||
conf = config[CONF_SOURCE]
|
conf = config[CONF_SOURCE]
|
||||||
if conf[CONF_TYPE] == TYPE_GIT:
|
if conf[CONF_TYPE] == TYPE_GIT:
|
||||||
with cv.prepend_path([CONF_SOURCE]):
|
with cv.prepend_path([CONF_SOURCE]):
|
||||||
components_dir = _process_git_config(
|
components_dir = _process_git_config(
|
||||||
config[CONF_SOURCE], config[CONF_REFRESH], skip_update
|
config[CONF_SOURCE], config[CONF_REFRESH]
|
||||||
)
|
)
|
||||||
elif conf[CONF_TYPE] == TYPE_LOCAL:
|
elif conf[CONF_TYPE] == TYPE_LOCAL:
|
||||||
components_dir = Path(CORE.relative_config_path(conf[CONF_PATH]))
|
components_dir = Path(CORE.relative_config_path(conf[CONF_PATH]))
|
||||||
@@ -107,7 +106,7 @@ def _process_single_config(config: dict, skip_update: bool = False):
|
|||||||
loader.install_meta_finder(components_dir, allowed_components=allowed_components)
|
loader.install_meta_finder(components_dir, allowed_components=allowed_components)
|
||||||
|
|
||||||
|
|
||||||
def do_external_components_pass(config: dict, skip_update: bool = False) -> None:
|
def do_external_components_pass(config: dict[str, Any]) -> None:
|
||||||
conf = config.get(DOMAIN)
|
conf = config.get(DOMAIN)
|
||||||
if conf is None:
|
if conf is None:
|
||||||
return
|
return
|
||||||
@@ -115,4 +114,4 @@ def do_external_components_pass(config: dict, skip_update: bool = False) -> None
|
|||||||
conf = CONFIG_SCHEMA(conf)
|
conf = CONFIG_SCHEMA(conf)
|
||||||
for i, c in enumerate(conf):
|
for i, c in enumerate(conf):
|
||||||
with cv.prepend_path(i):
|
with cv.prepend_path(i):
|
||||||
_process_single_config(c, skip_update)
|
_process_single_config(c)
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ CONFIG_SCHEMA = cv.Any( # under `packages:` we can have either:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _process_remote_package(config: dict, skip_update: bool = False) -> dict:
|
def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Clone/update a git repo and load the YAML files listed in the package definition.
|
"""Clone/update a git repo and load the YAML files listed in the package definition.
|
||||||
|
|
||||||
Returns ``{"packages": {<filename>: <loaded_yaml>, ...}}`` so the caller
|
Returns ``{"packages": {<filename>: <loaded_yaml>, ...}}`` so the caller
|
||||||
@@ -215,11 +215,10 @@ def _process_remote_package(config: dict, skip_update: bool = False) -> dict:
|
|||||||
If loading fails after cloning, attempts a revert and retry in case
|
If loading fails after cloning, attempts a revert and retry in case
|
||||||
a prior cached checkout is stale.
|
a prior cached checkout is stale.
|
||||||
"""
|
"""
|
||||||
actual_refresh = git.NEVER_REFRESH if skip_update else config[CONF_REFRESH]
|
|
||||||
repo_dir, revert = git.clone_or_update(
|
repo_dir, revert = git.clone_or_update(
|
||||||
url=config[CONF_URL],
|
url=config[CONF_URL],
|
||||||
ref=config.get(CONF_REF),
|
ref=config.get(CONF_REF),
|
||||||
refresh=actual_refresh,
|
refresh=config[CONF_REFRESH],
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
username=config.get(CONF_USERNAME),
|
username=config.get(CONF_USERNAME),
|
||||||
password=config.get(CONF_PASSWORD),
|
password=config.get(CONF_PASSWORD),
|
||||||
@@ -456,11 +455,9 @@ class _PackageProcessor:
|
|||||||
self,
|
self,
|
||||||
substitutions: UserDict,
|
substitutions: UserDict,
|
||||||
command_line_substitutions: dict[str, Any] | None,
|
command_line_substitutions: dict[str, Any] | None,
|
||||||
skip_update: bool,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.substitutions = substitutions
|
self.substitutions = substitutions
|
||||||
self.parent_context = UserDict(command_line_substitutions or {})
|
self.parent_context = UserDict(command_line_substitutions or {})
|
||||||
self.skip_update = skip_update
|
|
||||||
|
|
||||||
def resolve_package(
|
def resolve_package(
|
||||||
self,
|
self,
|
||||||
@@ -508,7 +505,7 @@ class _PackageProcessor:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if is_remote_package(package_config):
|
if is_remote_package(package_config):
|
||||||
package_config = _process_remote_package(package_config, self.skip_update)
|
package_config = _process_remote_package(package_config)
|
||||||
return package_config
|
return package_config
|
||||||
|
|
||||||
def collect_substitutions(self, package_config: dict) -> None:
|
def collect_substitutions(self, package_config: dict) -> None:
|
||||||
@@ -552,11 +549,10 @@ class _PackageProcessor:
|
|||||||
|
|
||||||
|
|
||||||
def do_packages_pass(
|
def do_packages_pass(
|
||||||
config: dict,
|
config: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
command_line_substitutions: dict[str, Any] | None = None,
|
command_line_substitutions: dict[str, Any] | None = None,
|
||||||
skip_update: bool = False,
|
) -> dict[str, Any]:
|
||||||
) -> dict:
|
|
||||||
"""Load, validate, and flatten all packages in the config.
|
"""Load, validate, and flatten all packages in the config.
|
||||||
|
|
||||||
Returns the config with all packages loaded in-place (but not yet merged)
|
Returns the config with all packages loaded in-place (but not yet merged)
|
||||||
@@ -571,9 +567,7 @@ def do_packages_pass(
|
|||||||
config.pop(CONF_SUBSTITUTIONS, {}), command_line_substitutions
|
config.pop(CONF_SUBSTITUTIONS, {}), command_line_substitutions
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
processor = _PackageProcessor(
|
processor = _PackageProcessor(substitutions, command_line_substitutions)
|
||||||
substitutions, command_line_substitutions, skip_update
|
|
||||||
)
|
|
||||||
_update_substitutions_context(processor.parent_context, substitutions)
|
_update_substitutions_context(processor.parent_context, substitutions)
|
||||||
|
|
||||||
context_vars = push_context(
|
context_vars = push_context(
|
||||||
|
|||||||
+6
-3
@@ -997,6 +997,8 @@ def validate_config(
|
|||||||
) -> Config:
|
) -> Config:
|
||||||
result = Config()
|
result = Config()
|
||||||
|
|
||||||
|
CORE.skip_external_update = skip_external_update
|
||||||
|
|
||||||
loader.clear_component_meta_finders()
|
loader.clear_component_meta_finders()
|
||||||
loader.install_custom_components_meta_finder()
|
loader.install_custom_components_meta_finder()
|
||||||
|
|
||||||
@@ -1009,7 +1011,6 @@ def validate_config(
|
|||||||
config = do_packages_pass(
|
config = do_packages_pass(
|
||||||
config,
|
config,
|
||||||
command_line_substitutions=command_line_substitutions,
|
command_line_substitutions=command_line_substitutions,
|
||||||
skip_update=skip_external_update,
|
|
||||||
)
|
)
|
||||||
except vol.Invalid as err:
|
except vol.Invalid as err:
|
||||||
result.update(config)
|
result.update(config)
|
||||||
@@ -1050,7 +1051,7 @@ def validate_config(
|
|||||||
|
|
||||||
result.add_output_path([CONF_EXTERNAL_COMPONENTS], CONF_EXTERNAL_COMPONENTS)
|
result.add_output_path([CONF_EXTERNAL_COMPONENTS], CONF_EXTERNAL_COMPONENTS)
|
||||||
try:
|
try:
|
||||||
do_external_components_pass(config, skip_update=skip_external_update)
|
do_external_components_pass(config)
|
||||||
except vol.Invalid as err:
|
except vol.Invalid as err:
|
||||||
result.update(config)
|
result.update(config)
|
||||||
result.add_error(err)
|
result.add_error(err)
|
||||||
@@ -1341,7 +1342,9 @@ def strip_default_ids(config):
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
def read_config(command_line_substitutions, skip_external_update=False):
|
def read_config(
|
||||||
|
command_line_substitutions: dict[str, Any], skip_external_update: bool = False
|
||||||
|
) -> Config | None:
|
||||||
_LOGGER.info("Reading configuration %s...", CORE.config_path)
|
_LOGGER.info("Reading configuration %s...", CORE.config_path)
|
||||||
try:
|
try:
|
||||||
res = load_config(command_line_substitutions, skip_external_update)
|
res = load_config(command_line_substitutions, skip_external_update)
|
||||||
|
|||||||
@@ -615,6 +615,9 @@ class EsphomeCore:
|
|||||||
self.address_cache: AddressCache | None = None
|
self.address_cache: AddressCache | None = None
|
||||||
# Cached config hash (computed lazily)
|
# Cached config hash (computed lazily)
|
||||||
self._config_hash: int | None = None
|
self._config_hash: int | None = None
|
||||||
|
# When True, skip network freshness checks for cached external files
|
||||||
|
# (e.g. for `esphome logs`, where remote downloads aren't needed)
|
||||||
|
self.skip_external_update: bool = False
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
from esphome.pins import PIN_SCHEMA_REGISTRY
|
from esphome.pins import PIN_SCHEMA_REGISTRY
|
||||||
@@ -644,6 +647,7 @@ class EsphomeCore:
|
|||||||
self.current_component = None
|
self.current_component = None
|
||||||
self.address_cache = None
|
self.address_cache = None
|
||||||
self._config_hash = None
|
self._config_hash = None
|
||||||
|
self.skip_external_update = False
|
||||||
PIN_SCHEMA_REGISTRY.reset()
|
PIN_SCHEMA_REGISTRY.reset()
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
|||||||
@@ -81,7 +81,10 @@ def compute_local_file_dir(domain: str) -> Path:
|
|||||||
return base_directory
|
return base_directory
|
||||||
|
|
||||||
|
|
||||||
def download_content(url: str, path: Path, timeout=NETWORK_TIMEOUT) -> bytes:
|
def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> bytes:
|
||||||
|
if CORE.skip_external_update and path.exists():
|
||||||
|
_LOGGER.debug("Skipping update for %s (refresh disabled)", url)
|
||||||
|
return path.read_bytes()
|
||||||
if not has_remote_file_changed(url, path):
|
if not has_remote_file_changed(url, path):
|
||||||
_LOGGER.debug("Remote file has not changed %s", url)
|
_LOGGER.debug("Remote file has not changed %s", url)
|
||||||
return path.read_bytes()
|
return path.read_bytes()
|
||||||
|
|||||||
+1
-3
@@ -150,9 +150,7 @@ def clone_or_update(
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Check refresh needed
|
if refresh == NEVER_REFRESH or CORE.skip_external_update:
|
||||||
# Skip refresh if NEVER_REFRESH is specified
|
|
||||||
if refresh == NEVER_REFRESH:
|
|
||||||
_LOGGER.debug("Skipping update for %s (refresh disabled)", key)
|
_LOGGER.debug("Skipping update for %s (refresh disabled)", key)
|
||||||
return repo_dir, None
|
return repo_dir, None
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for the external_components skip_update functionality."""
|
"""Tests for the external_components skip-update behavior driven by CORE.skip_external_update."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -12,25 +12,17 @@ from esphome.const import (
|
|||||||
CONF_URL,
|
CONF_URL,
|
||||||
TYPE_GIT,
|
TYPE_GIT,
|
||||||
)
|
)
|
||||||
|
from esphome.core import CORE, TimePeriodSeconds
|
||||||
|
|
||||||
|
|
||||||
def test_external_components_skip_update_true(
|
def _make_config(tmp_path: Path) -> dict[str, Any]:
|
||||||
tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock
|
|
||||||
) -> None:
|
|
||||||
"""Test that external components don't update when skip_update=True."""
|
|
||||||
# Create a components directory structure
|
|
||||||
components_dir = tmp_path / "components"
|
components_dir = tmp_path / "components"
|
||||||
components_dir.mkdir()
|
components_dir.mkdir()
|
||||||
|
|
||||||
# Create a test component
|
|
||||||
test_component_dir = components_dir / "test_component"
|
test_component_dir = components_dir / "test_component"
|
||||||
test_component_dir.mkdir()
|
test_component_dir.mkdir()
|
||||||
(test_component_dir / "__init__.py").write_text("# Test component")
|
(test_component_dir / "__init__.py").write_text("# Test component")
|
||||||
|
|
||||||
# Set up mock to return our tmp_path
|
return {
|
||||||
mock_clone_or_update.return_value = (tmp_path, None)
|
|
||||||
|
|
||||||
config: dict[str, Any] = {
|
|
||||||
CONF_EXTERNAL_COMPONENTS: [
|
CONF_EXTERNAL_COMPONENTS: [
|
||||||
{
|
{
|
||||||
CONF_SOURCE: {
|
CONF_SOURCE: {
|
||||||
@@ -43,92 +35,37 @@ def test_external_components_skip_update_true(
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Call with skip_update=True
|
|
||||||
do_external_components_pass(config, skip_update=True)
|
|
||||||
|
|
||||||
# Verify clone_or_update was called with NEVER_REFRESH
|
def test_external_components_skip_update_via_core_flag(
|
||||||
mock_clone_or_update.assert_called_once()
|
tmp_path: Path,
|
||||||
call_args = mock_clone_or_update.call_args
|
mock_clone_or_update: MagicMock,
|
||||||
from esphome import git
|
mock_install_meta_finder: MagicMock,
|
||||||
|
|
||||||
assert call_args.kwargs["refresh"] == git.NEVER_REFRESH
|
|
||||||
|
|
||||||
|
|
||||||
def test_external_components_skip_update_false(
|
|
||||||
tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that external components update when skip_update=False."""
|
"""When CORE.skip_external_update is True, refresh is still passed through;
|
||||||
# Create a components directory structure
|
git.clone_or_update itself short-circuits the actual fetch."""
|
||||||
components_dir = tmp_path / "components"
|
|
||||||
components_dir.mkdir()
|
|
||||||
|
|
||||||
# Create a test component
|
|
||||||
test_component_dir = components_dir / "test_component"
|
|
||||||
test_component_dir.mkdir()
|
|
||||||
(test_component_dir / "__init__.py").write_text("# Test component")
|
|
||||||
|
|
||||||
# Set up mock to return our tmp_path
|
|
||||||
mock_clone_or_update.return_value = (tmp_path, None)
|
mock_clone_or_update.return_value = (tmp_path, None)
|
||||||
|
config = _make_config(tmp_path)
|
||||||
|
|
||||||
|
CORE.skip_external_update = True
|
||||||
|
do_external_components_pass(config)
|
||||||
|
|
||||||
|
mock_clone_or_update.assert_called_once()
|
||||||
|
call_args = mock_clone_or_update.call_args
|
||||||
|
# Refresh is passed through verbatim — the global flag is enforced inside git.clone_or_update.
|
||||||
|
assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_components_normal_refresh(
|
||||||
|
tmp_path: Path,
|
||||||
|
mock_clone_or_update: MagicMock,
|
||||||
|
mock_install_meta_finder: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""When CORE.skip_external_update is False, the configured refresh value is used."""
|
||||||
|
mock_clone_or_update.return_value = (tmp_path, None)
|
||||||
|
config = _make_config(tmp_path)
|
||||||
|
|
||||||
config: dict[str, Any] = {
|
|
||||||
CONF_EXTERNAL_COMPONENTS: [
|
|
||||||
{
|
|
||||||
CONF_SOURCE: {
|
|
||||||
"type": TYPE_GIT,
|
|
||||||
CONF_URL: "https://github.com/test/components",
|
|
||||||
},
|
|
||||||
CONF_REFRESH: "1d",
|
|
||||||
"components": "all",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Call with skip_update=False
|
|
||||||
do_external_components_pass(config, skip_update=False)
|
|
||||||
|
|
||||||
# Verify clone_or_update was called with actual refresh value
|
|
||||||
mock_clone_or_update.assert_called_once()
|
|
||||||
call_args = mock_clone_or_update.call_args
|
|
||||||
from esphome.core import TimePeriodSeconds
|
|
||||||
|
|
||||||
assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_external_components_default_no_skip(
|
|
||||||
tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock
|
|
||||||
) -> None:
|
|
||||||
"""Test that external components update by default when skip_update not specified."""
|
|
||||||
# Create a components directory structure
|
|
||||||
components_dir = tmp_path / "components"
|
|
||||||
components_dir.mkdir()
|
|
||||||
|
|
||||||
# Create a test component
|
|
||||||
test_component_dir = components_dir / "test_component"
|
|
||||||
test_component_dir.mkdir()
|
|
||||||
(test_component_dir / "__init__.py").write_text("# Test component")
|
|
||||||
|
|
||||||
# Set up mock to return our tmp_path
|
|
||||||
mock_clone_or_update.return_value = (tmp_path, None)
|
|
||||||
|
|
||||||
config: dict[str, Any] = {
|
|
||||||
CONF_EXTERNAL_COMPONENTS: [
|
|
||||||
{
|
|
||||||
CONF_SOURCE: {
|
|
||||||
"type": TYPE_GIT,
|
|
||||||
CONF_URL: "https://github.com/test/components",
|
|
||||||
},
|
|
||||||
CONF_REFRESH: "1d",
|
|
||||||
"components": "all",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Call without skip_update parameter
|
|
||||||
do_external_components_pass(config)
|
do_external_components_pass(config)
|
||||||
|
|
||||||
# Verify clone_or_update was called with actual refresh value
|
|
||||||
mock_clone_or_update.assert_called_once()
|
mock_clone_or_update.assert_called_once()
|
||||||
call_args = mock_clone_or_update.call_args
|
call_args = mock_clone_or_update.call_args
|
||||||
from esphome.core import TimePeriodSeconds
|
|
||||||
|
|
||||||
assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1)
|
assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for the packages component skip_update functionality."""
|
"""Tests for the packages skip-update behavior driven by CORE.skip_external_update."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -6,24 +6,12 @@ from unittest.mock import MagicMock
|
|||||||
|
|
||||||
from esphome.components.packages import do_packages_pass
|
from esphome.components.packages import do_packages_pass
|
||||||
from esphome.const import CONF_FILES, CONF_PACKAGES, CONF_REFRESH, CONF_URL
|
from esphome.const import CONF_FILES, CONF_PACKAGES, CONF_REFRESH, CONF_URL
|
||||||
|
from esphome.core import CORE, TimePeriodSeconds
|
||||||
from esphome.util import OrderedDict
|
from esphome.util import OrderedDict
|
||||||
|
|
||||||
|
|
||||||
def test_packages_skip_update_true(
|
def _make_config() -> dict[str, Any]:
|
||||||
tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock
|
return {
|
||||||
) -> None:
|
|
||||||
"""Test that packages don't update when skip_update=True."""
|
|
||||||
# Set up mock to return our tmp_path
|
|
||||||
mock_clone_or_update.return_value = (tmp_path, None)
|
|
||||||
|
|
||||||
# Create the test yaml file
|
|
||||||
test_file = tmp_path / "test.yaml"
|
|
||||||
test_file.write_text("sensor: []")
|
|
||||||
|
|
||||||
# Set mock_load_yaml to return some valid config
|
|
||||||
mock_load_yaml.return_value = OrderedDict({"sensor": []})
|
|
||||||
|
|
||||||
config: dict[str, Any] = {
|
|
||||||
CONF_PACKAGES: {
|
CONF_PACKAGES: {
|
||||||
"test_package": {
|
"test_package": {
|
||||||
CONF_URL: "https://github.com/test/repo",
|
CONF_URL: "https://github.com/test/repo",
|
||||||
@@ -33,82 +21,47 @@ def test_packages_skip_update_true(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Call with skip_update=True
|
|
||||||
do_packages_pass(config, skip_update=True)
|
|
||||||
|
|
||||||
# Verify clone_or_update was called with NEVER_REFRESH
|
def test_packages_skip_update_via_core_flag(
|
||||||
mock_clone_or_update.assert_called_once()
|
tmp_path: Path,
|
||||||
call_args = mock_clone_or_update.call_args
|
mock_clone_or_update: MagicMock,
|
||||||
from esphome import git
|
mock_load_yaml: MagicMock,
|
||||||
|
|
||||||
assert call_args.kwargs["refresh"] == git.NEVER_REFRESH
|
|
||||||
|
|
||||||
|
|
||||||
def test_packages_skip_update_false(
|
|
||||||
tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that packages update when skip_update=False."""
|
"""When CORE.skip_external_update is True, refresh is still passed through;
|
||||||
# Set up mock to return our tmp_path
|
git.clone_or_update itself short-circuits the actual fetch."""
|
||||||
mock_clone_or_update.return_value = (tmp_path, None)
|
mock_clone_or_update.return_value = (tmp_path, None)
|
||||||
|
|
||||||
# Create the test yaml file
|
|
||||||
test_file = tmp_path / "test.yaml"
|
test_file = tmp_path / "test.yaml"
|
||||||
test_file.write_text("sensor: []")
|
test_file.write_text("sensor: []")
|
||||||
|
|
||||||
# Set mock_load_yaml to return some valid config
|
|
||||||
mock_load_yaml.return_value = OrderedDict({"sensor": []})
|
mock_load_yaml.return_value = OrderedDict({"sensor": []})
|
||||||
|
|
||||||
config: dict[str, Any] = {
|
config = _make_config()
|
||||||
CONF_PACKAGES: {
|
|
||||||
"test_package": {
|
CORE.skip_external_update = True
|
||||||
CONF_URL: "https://github.com/test/repo",
|
do_packages_pass(config, command_line_substitutions={})
|
||||||
CONF_FILES: ["test.yaml"],
|
|
||||||
CONF_REFRESH: "1d",
|
mock_clone_or_update.assert_called_once()
|
||||||
}
|
call_args = mock_clone_or_update.call_args
|
||||||
}
|
# Refresh is passed through verbatim — the global flag is enforced inside git.clone_or_update.
|
||||||
}
|
assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_packages_normal_refresh(
|
||||||
|
tmp_path: Path,
|
||||||
|
mock_clone_or_update: MagicMock,
|
||||||
|
mock_load_yaml: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""When CORE.skip_external_update is False, the configured refresh value is used."""
|
||||||
|
mock_clone_or_update.return_value = (tmp_path, None)
|
||||||
|
|
||||||
|
test_file = tmp_path / "test.yaml"
|
||||||
|
test_file.write_text("sensor: []")
|
||||||
|
mock_load_yaml.return_value = OrderedDict({"sensor": []})
|
||||||
|
|
||||||
|
config = _make_config()
|
||||||
|
|
||||||
# Call with skip_update=False (default)
|
|
||||||
do_packages_pass(config, command_line_substitutions={}, skip_update=False)
|
|
||||||
|
|
||||||
# Verify clone_or_update was called with actual refresh value
|
|
||||||
mock_clone_or_update.assert_called_once()
|
|
||||||
call_args = mock_clone_or_update.call_args
|
|
||||||
from esphome.core import TimePeriodSeconds
|
|
||||||
|
|
||||||
assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_packages_default_no_skip(
|
|
||||||
tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock
|
|
||||||
) -> None:
|
|
||||||
"""Test that packages update by default when skip_update not specified."""
|
|
||||||
# Set up mock to return our tmp_path
|
|
||||||
mock_clone_or_update.return_value = (tmp_path, None)
|
|
||||||
|
|
||||||
# Create the test yaml file
|
|
||||||
test_file = tmp_path / "test.yaml"
|
|
||||||
test_file.write_text("sensor: []")
|
|
||||||
|
|
||||||
# Set mock_load_yaml to return some valid config
|
|
||||||
mock_load_yaml.return_value = OrderedDict({"sensor": []})
|
|
||||||
|
|
||||||
config: dict[str, Any] = {
|
|
||||||
CONF_PACKAGES: {
|
|
||||||
"test_package": {
|
|
||||||
CONF_URL: "https://github.com/test/repo",
|
|
||||||
CONF_FILES: ["test.yaml"],
|
|
||||||
CONF_REFRESH: "1d",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Call without skip_update parameter
|
|
||||||
do_packages_pass(config, command_line_substitutions={})
|
do_packages_pass(config, command_line_substitutions={})
|
||||||
|
|
||||||
# Verify clone_or_update was called with actual refresh value
|
|
||||||
mock_clone_or_update.assert_called_once()
|
mock_clone_or_update.assert_called_once()
|
||||||
call_args = mock_clone_or_update.call_args
|
call_args = mock_clone_or_update.call_args
|
||||||
from esphome.core import TimePeriodSeconds
|
|
||||||
|
|
||||||
assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1)
|
assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1)
|
||||||
|
|||||||
@@ -236,3 +236,49 @@ def test_download_content_with_network_error_no_cache_fails(
|
|||||||
|
|
||||||
with pytest.raises(Invalid, match="Could not download from.*Network error"):
|
with pytest.raises(Invalid, match="Could not download from.*Network error"):
|
||||||
external_files.download_content(url, test_file)
|
external_files.download_content(url, test_file)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("esphome.external_files.requests.get")
|
||||||
|
@patch("esphome.external_files.has_remote_file_changed")
|
||||||
|
def test_download_content_skip_external_update_uses_cache(
|
||||||
|
mock_has_changed: MagicMock,
|
||||||
|
mock_get: MagicMock,
|
||||||
|
setup_core: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test download_content skips network checks when CORE.skip_external_update is set."""
|
||||||
|
test_file = setup_core / "cached.txt"
|
||||||
|
cached_content = b"cached content"
|
||||||
|
test_file.write_bytes(cached_content)
|
||||||
|
|
||||||
|
CORE.skip_external_update = True
|
||||||
|
url = "https://example.com/file.txt"
|
||||||
|
result = external_files.download_content(url, test_file)
|
||||||
|
|
||||||
|
assert result == cached_content
|
||||||
|
mock_has_changed.assert_not_called()
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("esphome.external_files.requests.get")
|
||||||
|
@patch("esphome.external_files.has_remote_file_changed")
|
||||||
|
def test_download_content_skip_external_update_downloads_when_missing(
|
||||||
|
mock_has_changed: MagicMock,
|
||||||
|
mock_get: MagicMock,
|
||||||
|
setup_core: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test download_content still downloads when file is missing, even with skip_external_update."""
|
||||||
|
test_file = setup_core / "missing.txt"
|
||||||
|
new_content = b"fresh content"
|
||||||
|
|
||||||
|
mock_has_changed.return_value = True
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.content = new_content
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
CORE.skip_external_update = True
|
||||||
|
url = "https://example.com/file.txt"
|
||||||
|
result = external_files.download_content(url, test_file)
|
||||||
|
|
||||||
|
assert result == new_content
|
||||||
|
assert test_file.read_bytes() == new_content
|
||||||
|
|||||||
@@ -236,6 +236,35 @@ def test_clone_or_update_with_never_refresh(
|
|||||||
assert revert is None
|
assert revert is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_clone_or_update_skips_when_core_skip_external_update(
|
||||||
|
tmp_path: Path, mock_run_git_command: Mock
|
||||||
|
) -> None:
|
||||||
|
"""CORE.skip_external_update short-circuits the refresh for existing repos."""
|
||||||
|
CORE.config_path = tmp_path / "test.yaml"
|
||||||
|
|
||||||
|
url = "https://github.com/test/repo"
|
||||||
|
ref = None
|
||||||
|
domain = "test"
|
||||||
|
repo_dir = _compute_repo_dir(url, ref, domain)
|
||||||
|
|
||||||
|
repo_dir.mkdir(parents=True)
|
||||||
|
git_dir = repo_dir / ".git"
|
||||||
|
git_dir.mkdir()
|
||||||
|
(git_dir / "FETCH_HEAD").write_text("test")
|
||||||
|
|
||||||
|
CORE.skip_external_update = True
|
||||||
|
result_dir, revert = git.clone_or_update(
|
||||||
|
url=url,
|
||||||
|
ref=ref,
|
||||||
|
refresh=TimePeriodSeconds(days=1),
|
||||||
|
domain=domain,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_run_git_command.assert_not_called()
|
||||||
|
assert result_dir == repo_dir
|
||||||
|
assert revert is None
|
||||||
|
|
||||||
|
|
||||||
def test_clone_or_update_with_refresh_updates_old_repo(
|
def test_clone_or_update_with_refresh_updates_old_repo(
|
||||||
tmp_path: Path, mock_run_git_command: Mock
|
tmp_path: Path, mock_run_git_command: Mock
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -654,7 +654,7 @@ def test_resolve_package_max_depth_exceeded(tmp_path: Path) -> None:
|
|||||||
package_config = yaml_util.IncludeFile(
|
package_config = yaml_util.IncludeFile(
|
||||||
parent, "test.yaml", None, always_returns_include
|
parent, "test.yaml", None, always_returns_include
|
||||||
)
|
)
|
||||||
processor = _PackageProcessor({}, None, False)
|
processor = _PackageProcessor({}, None)
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
cv.Invalid,
|
cv.Invalid,
|
||||||
match=f"Maximum include nesting depth \\({MAX_INCLUDE_DEPTH}\\) exceeded",
|
match=f"Maximum include nesting depth \\({MAX_INCLUDE_DEPTH}\\) exceeded",
|
||||||
@@ -776,7 +776,7 @@ def test_resolve_package_undefined_var_in_include_filename(tmp_path: Path) -> No
|
|||||||
package_config = yaml_util.IncludeFile(
|
package_config = yaml_util.IncludeFile(
|
||||||
parent, "${undefined_var}.yaml", None, loader
|
parent, "${undefined_var}.yaml", None, loader
|
||||||
)
|
)
|
||||||
processor = _PackageProcessor({}, None, False)
|
processor = _PackageProcessor({}, None)
|
||||||
with pytest.raises(cv.Invalid, match="unresolved substitutions"):
|
with pytest.raises(cv.Invalid, match="unresolved substitutions"):
|
||||||
processor.resolve_package(package_config, substitutions.ContextVars(), [])
|
processor.resolve_package(package_config, substitutions.ContextVars(), [])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user