mirror of
https://github.com/esphome/esphome.git
synced 2026-10-02 01:10:22 +00:00
[core] Add a line based yaml editor and use it for rename (#19410)
This commit is contained in:
@@ -0,0 +1,839 @@
|
||||
"""Tests for ``esphome rename``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from pytest import CaptureFixture
|
||||
|
||||
from esphome.cli.rename import command_rename
|
||||
from esphome.const import CONF_ESPHOME, CONF_NAME, CONF_SUBSTITUTIONS
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockArgs:
|
||||
name: str | None = None
|
||||
dashboard: bool = False
|
||||
|
||||
|
||||
def setup_core(tmp_path: Path, config: dict[str, Any] | None = None) -> None:
|
||||
"""Point CORE at a config in ``tmp_path``; the tests override the path."""
|
||||
CORE.config = config or {}
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
CORE.name = "test"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_run_external_process() -> Generator[Mock]:
|
||||
"""The child esphome the command starts to validate and install."""
|
||||
with patch("esphome.cli.rename.run_external_process") as mock:
|
||||
mock.return_value = 0
|
||||
yield mock
|
||||
|
||||
|
||||
def test_command_rename_invalid_characters(
|
||||
tmp_path: Path, capfd: CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Test command_rename with invalid characters in name."""
|
||||
setup_core(tmp_path=tmp_path)
|
||||
|
||||
# Test with invalid character (space)
|
||||
args = MockArgs(name="invalid name")
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
captured = capfd.readouterr()
|
||||
assert "invalid character" in captured.out.lower()
|
||||
|
||||
|
||||
def test_command_rename_complex_yaml(
|
||||
tmp_path: Path, capfd: CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Test command_rename with complex YAML that cannot be renamed."""
|
||||
config_file = tmp_path / "test.yaml"
|
||||
config_file.write_text("# Complex YAML without esphome section\nsome_key: value\n")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
|
||||
args = MockArgs(name="newname")
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
captured = capfd.readouterr()
|
||||
assert "complex yaml" in captured.out.lower()
|
||||
|
||||
|
||||
def test_command_rename_success(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test successful rename of a simple configuration."""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: oldname
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
|
||||
wifi:
|
||||
ssid: "test"
|
||||
password: "test1234"
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
|
||||
# Set up CORE.config to avoid ValueError when accessing CORE.address
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
|
||||
args = MockArgs(name="newname", dashboard=False)
|
||||
|
||||
# Simulate successful validation and upload
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
|
||||
# Verify new file was created
|
||||
new_file = tmp_path / "newname.yaml"
|
||||
assert new_file.exists()
|
||||
|
||||
# Verify old file was removed
|
||||
assert not config_file.exists()
|
||||
|
||||
# Verify content was updated
|
||||
content = new_file.read_text()
|
||||
assert (
|
||||
'name: "newname"' in content
|
||||
or "name: 'newname'" in content
|
||||
or "name: newname" in content
|
||||
)
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "SUCCESS" in captured.out
|
||||
|
||||
|
||||
def test_command_rename_with_substitutions(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename with substitutions in YAML."""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("""
|
||||
substitutions:
|
||||
device_name: oldname
|
||||
|
||||
esphome:
|
||||
name: ${device_name}
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
|
||||
# Set up CORE.config to avoid ValueError when accessing CORE.address
|
||||
CORE.config = {
|
||||
CONF_ESPHOME: {CONF_NAME: "oldname"},
|
||||
CONF_SUBSTITUTIONS: {"device_name": "oldname"},
|
||||
}
|
||||
|
||||
args = MockArgs(name="newname", dashboard=False)
|
||||
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
|
||||
# Verify substitution was updated
|
||||
new_file = tmp_path / "newname.yaml"
|
||||
content = new_file.read_text()
|
||||
assert 'device_name: "newname"' in content
|
||||
|
||||
|
||||
def test_command_rename_validation_failure(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename when validation fails."""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: oldname
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
|
||||
args = MockArgs(name="newname", dashboard=False)
|
||||
|
||||
# First call for validation fails
|
||||
mock_run_external_process.return_value = 1
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
|
||||
# Verify new file was created but then removed due to failure
|
||||
new_file = tmp_path / "newname.yaml"
|
||||
assert not new_file.exists()
|
||||
|
||||
# Verify old file still exists (not removed on failure)
|
||||
assert config_file.exists()
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "Rename failed" in captured.out
|
||||
|
||||
|
||||
def test_command_rename_install_failure_reverts(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename when the install (esphome run) step fails."""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: oldname
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
|
||||
args = MockArgs(name="newname", dashboard=False)
|
||||
|
||||
# First call (config validation) succeeds; second (esphome run) fails.
|
||||
mock_run_external_process.side_effect = [0, 1]
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
|
||||
# New file was unlinked when install failed.
|
||||
new_file = tmp_path / "newname.yaml"
|
||||
assert not new_file.exists()
|
||||
|
||||
# Old file is preserved so the device stays reachable under the
|
||||
# original hostname.
|
||||
assert config_file.exists()
|
||||
|
||||
|
||||
def test_command_rename_target_exists_refuses(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename refuses when the target filename already exists.
|
||||
|
||||
Without this guard, the rename would overwrite the unrelated
|
||||
device's YAML and OTA-install our firmware to the wrong device.
|
||||
"""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: oldname
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
target_file = tmp_path / "newname.yaml"
|
||||
target_file.write_text("""
|
||||
esphome:
|
||||
name: someoneelse
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
target_original = target_file.read_text()
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
|
||||
args = MockArgs(name="newname", dashboard=False)
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
# No subprocess work happened — refusal is up-front.
|
||||
mock_run_external_process.assert_not_called()
|
||||
# Target file untouched: same content, still on disk.
|
||||
assert target_file.exists()
|
||||
assert target_file.read_text() == target_original
|
||||
# Source file untouched.
|
||||
assert config_file.exists()
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "already exists" in captured.out
|
||||
|
||||
|
||||
def test_command_rename_same_name_refuses(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename refuses when the new name matches the current name.
|
||||
|
||||
A same-name rename would otherwise re-write the YAML and queue
|
||||
a redundant compile + install — wasted work the user almost
|
||||
certainly didn't intend.
|
||||
"""
|
||||
config_file = tmp_path / "samename.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: samename
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "samename"}}
|
||||
|
||||
args = MockArgs(name="samename", dashboard=False)
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
mock_run_external_process.assert_not_called()
|
||||
# File preserved verbatim — no rewrite happened.
|
||||
assert config_file.exists()
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "already" in captured.out.lower()
|
||||
|
||||
|
||||
def test_command_rename_does_not_touch_friendly_name_substring(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
r"""Test rename does not match the ``name:`` substring of ``friendly_name:``.
|
||||
|
||||
Without anchoring the regex at line start, the pattern
|
||||
``\s*name:\s+<old>`` could match the trailing ``name:``
|
||||
substring inside ``friendly_name: <old>``. The rewrite would
|
||||
flip both lines to the new name, leaving the user with a
|
||||
silently corrupted ``friendly_name``.
|
||||
"""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: oldname
|
||||
friendly_name: oldname
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
|
||||
args = MockArgs(name="newname", dashboard=False)
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
new_file = tmp_path / "newname.yaml"
|
||||
content = new_file.read_text()
|
||||
# esphome.name swapped.
|
||||
assert 'name: "newname"' in content
|
||||
# friendly_name kept verbatim.
|
||||
assert "friendly_name: oldname" in content
|
||||
|
||||
|
||||
def test_command_rename_does_not_match_old_name_as_value_prefix(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
r"""Test rename does not match ``old_name`` as a prefix of a longer value.
|
||||
|
||||
With ``old_name = kitchen`` the value ``kitchen2`` (a sensor
|
||||
or wifi entry) would otherwise match the unanchored
|
||||
``["']?kitchen["']?`` pattern at the prefix and get
|
||||
rewritten to the new name. The end-of-value lookahead keeps
|
||||
the match restricted to whole tokens.
|
||||
"""
|
||||
config_file = tmp_path / "kitchen.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: kitchen
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
|
||||
wifi:
|
||||
ap:
|
||||
ssid: kitchen2
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
|
||||
|
||||
args = MockArgs(name="garage", dashboard=False)
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
new_file = tmp_path / "garage.yaml"
|
||||
content = new_file.read_text()
|
||||
assert 'name: "garage"' in content
|
||||
# The wifi ssid value is unrelated and stays intact.
|
||||
assert "ssid: kitchen2" in content
|
||||
|
||||
|
||||
def test_command_rename_same_resolved_name_refuses(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename refuses when ``new_name`` matches the resolved device name.
|
||||
|
||||
The path-equality check only catches the case where the
|
||||
config filename matches the device name. For a config whose
|
||||
filename and ``esphome.name`` differ (here ``weird-file.yaml``
|
||||
holds ``esphome.name: kitchen``), running
|
||||
``esphome rename weird-file.yaml kitchen`` would otherwise
|
||||
fall through to the rewrite + install: the YAML's name stays
|
||||
``kitchen``, the file is renamed to ``kitchen.yaml``, and the
|
||||
device gets a redundant flash. Refuse up-front so the
|
||||
"already the device's name" message matches reality.
|
||||
"""
|
||||
config_file = tmp_path / "weird-file.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: kitchen
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
|
||||
|
||||
args = MockArgs(name="kitchen", dashboard=False)
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
mock_run_external_process.assert_not_called()
|
||||
# Source file untouched, no derived target written.
|
||||
assert config_file.exists()
|
||||
assert not (tmp_path / "kitchen.yaml").exists()
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "already" in captured.out.lower()
|
||||
|
||||
|
||||
def test_command_rename_target_path_equals_source_refuses(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename refuses when the new path resolves to the source file.
|
||||
|
||||
Reachable only when the YAML's filename and ``esphome.name``
|
||||
disagree — here ``kitchen.yaml`` holds ``esphome.name: garage``
|
||||
and the user runs ``esphome rename kitchen.yaml kitchen``. The
|
||||
name-equality check above passes (``garage != kitchen``), but
|
||||
``<config_dir>/kitchen.yaml`` resolves to the source file
|
||||
itself, so the rewrite would clobber the source mid-rename.
|
||||
Refuse rather than silently overwriting.
|
||||
"""
|
||||
config_file = tmp_path / "kitchen.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: garage
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "garage"}}
|
||||
|
||||
args = MockArgs(name="kitchen", dashboard=False)
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
mock_run_external_process.assert_not_called()
|
||||
# Source file still present and unmodified.
|
||||
assert config_file.exists()
|
||||
assert "name: garage" in config_file.read_text()
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "already" in captured.out.lower()
|
||||
|
||||
|
||||
def test_command_rename_does_not_touch_lookalike_name_in_other_blocks(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename only swaps the esphome.name line.
|
||||
|
||||
A device whose name happens to match a sensor's / output's
|
||||
``name:`` value must not have those other names rewritten —
|
||||
they're independent. Without an anchor for the esphome block
|
||||
a naive regex would clobber every line whose value matches.
|
||||
"""
|
||||
config_file = tmp_path / "kitchen.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: kitchen
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: kitchen
|
||||
lambda: 'return 0;'
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
|
||||
|
||||
args = MockArgs(name="garage", dashboard=False)
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
|
||||
new_file = tmp_path / "garage.yaml"
|
||||
content = new_file.read_text()
|
||||
# esphome.name renamed.
|
||||
assert 'name: "garage"' in content
|
||||
# Sensor's name is the user's entity name — must not be touched.
|
||||
assert " name: kitchen\n" in content
|
||||
|
||||
|
||||
def test_command_rename_preserves_trailing_comment(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename preserves a trailing ``# comment`` on the name line."""
|
||||
config_file = tmp_path / "kitchen.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: kitchen # primary device
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
|
||||
|
||||
args = MockArgs(name="garage", dashboard=False)
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
|
||||
new_file = tmp_path / "garage.yaml"
|
||||
content = new_file.read_text()
|
||||
assert "# primary device" in content
|
||||
|
||||
|
||||
def test_command_rename_handles_double_quoted_value(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename matches when the existing value is double-quoted."""
|
||||
config_file = tmp_path / "kitchen.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: "kitchen"
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
|
||||
|
||||
args = MockArgs(name="garage", dashboard=False)
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
new_file = tmp_path / "garage.yaml"
|
||||
assert 'name: "garage"' in new_file.read_text()
|
||||
|
||||
|
||||
def test_command_rename_handles_single_quoted_value(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename matches when the existing value is single-quoted."""
|
||||
config_file = tmp_path / "kitchen.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: 'kitchen'
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
|
||||
|
||||
args = MockArgs(name="garage", dashboard=False)
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
new_file = tmp_path / "garage.yaml"
|
||||
assert 'name: "garage"' in new_file.read_text()
|
||||
|
||||
|
||||
def test_command_rename_leaves_a_lookalike_substitution_line_alone(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Only the substitution's own line changes; another block's field of
|
||||
the same name and value is not it."""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("""
|
||||
substitutions:
|
||||
device_name: oldname
|
||||
|
||||
esphome:
|
||||
name: ${device_name}
|
||||
|
||||
example:
|
||||
device_name: oldname
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {
|
||||
CONF_ESPHOME: {CONF_NAME: "oldname"},
|
||||
CONF_SUBSTITUTIONS: {"device_name": "oldname"},
|
||||
}
|
||||
assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 0
|
||||
content = (tmp_path / "newname.yaml").read_text()
|
||||
assert 'device_name: "newname"' in content
|
||||
assert "example:\n device_name: oldname\n" in content
|
||||
|
||||
|
||||
def test_command_rename_keeps_line_endings_and_mode(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""A CRLF file stays CRLF and the new file gets the old one's mode."""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_bytes(
|
||||
b"esphome:\r\n name: oldname # device\r\n\r\nesp32:\r\n board: nodemcu-32s\r\n"
|
||||
)
|
||||
if sys.platform != "win32":
|
||||
config_file.chmod(0o600)
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 0
|
||||
new_file = tmp_path / "newname.yaml"
|
||||
assert new_file.read_bytes() == (
|
||||
b'esphome:\r\n name: "newname" # device\r\n\r\nesp32:\r\n board: nodemcu-32s\r\n'
|
||||
)
|
||||
if sys.platform != "win32":
|
||||
assert new_file.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("yaml_text", "extra"),
|
||||
[
|
||||
("esphome:\n name: ${missing}\n", {}),
|
||||
("esphome: {name: oldname}\n", {}),
|
||||
("esphome: !include base.yaml\n", {"base.yaml": "name: oldname\n"}),
|
||||
(
|
||||
(
|
||||
"named: &named\n name: oldname\n\nesphome:\n <<: *named\n\n"
|
||||
"sensor:\n - platform: template\n <<: *named\n"
|
||||
),
|
||||
{},
|
||||
),
|
||||
],
|
||||
ids=["missing_substitution", "flow_mapping", "included_name", "merged_name"],
|
||||
)
|
||||
def test_command_rename_refuses_shapes_without_a_plain_name_line(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
yaml_text: str,
|
||||
extra: dict[str, str],
|
||||
) -> None:
|
||||
"""The name line must be a plain value in the file being renamed."""
|
||||
for name, text in extra.items():
|
||||
(tmp_path / name).write_text(text)
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text(yaml_text)
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1
|
||||
mock_run_external_process.assert_not_called()
|
||||
assert "complex yaml" in capfd.readouterr().out.lower()
|
||||
|
||||
|
||||
def test_command_rename_removes_the_new_file_when_its_mode_cannot_be_set(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""No orphan is left for the next attempt to trip over."""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("esphome:\n name: oldname\n")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
with patch("pathlib.Path.chmod", side_effect=OSError("read-only share")):
|
||||
assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1
|
||||
assert not (tmp_path / "newname.yaml").exists()
|
||||
mock_run_external_process.assert_not_called()
|
||||
assert "Rename failed" in capfd.readouterr().out
|
||||
|
||||
|
||||
def test_command_rename_refuses_a_name_without_a_source_line(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""A key the loader did not read from a file cannot be located."""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("esphome:\n name: oldname\n")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
with patch("esphome.yaml_edit.source_of", return_value=None):
|
||||
assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1
|
||||
mock_run_external_process.assert_not_called()
|
||||
assert "was not read from" in capfd.readouterr().out
|
||||
|
||||
|
||||
def test_command_rename_passes_dashboard_to_the_install(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("esphome:\n name: oldname\n")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
assert command_rename(MockArgs(name="newname", dashboard=True), {}) == 0
|
||||
install = mock_run_external_process.call_args_list[-1].args
|
||||
assert install[-6:-4] == ("--dashboard", "run")
|
||||
|
||||
|
||||
def test_command_rename_interrupted_install_reverts(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("esphome:\n name: oldname\n")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
mock_run_external_process.side_effect = [0, KeyboardInterrupt]
|
||||
assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1
|
||||
assert not (tmp_path / "newname.yaml").exists()
|
||||
assert config_file.exists()
|
||||
|
||||
|
||||
def test_command_rename_reads_a_config_linked_from_outside(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""The source is only read; the new file lands in the config directory."""
|
||||
outside = tmp_path / "elsewhere.yaml"
|
||||
outside.write_text("esphome:\n name: oldname\n")
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
config_file = config_dir / "oldname.yaml"
|
||||
config_file.symlink_to(outside)
|
||||
setup_core(tmp_path=config_dir)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 0
|
||||
assert (config_dir / "newname.yaml").read_text() == 'esphome:\n name: "newname"\n'
|
||||
assert not config_file.exists()
|
||||
assert outside.exists()
|
||||
|
||||
|
||||
def test_command_rename_reports_an_orphan_it_could_not_remove(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""The write failure is the message; a cleanup failure is added to it."""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("esphome:\n name: oldname\n")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
with (
|
||||
patch("pathlib.Path.chmod", side_effect=OSError("read-only share")),
|
||||
patch("pathlib.Path.unlink", side_effect=OSError("busy")),
|
||||
):
|
||||
assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1
|
||||
out = capfd.readouterr().out
|
||||
assert "Rename failed" in out
|
||||
assert "Could not remove" in out and "newname.yaml" in out
|
||||
|
||||
|
||||
def test_command_rename_install_failure_says_so(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""The device may already carry the new name; the user is told."""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("esphome:\n name: oldname\n")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
mock_run_external_process.side_effect = [0, 1]
|
||||
assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1
|
||||
out = capfd.readouterr().out
|
||||
assert "Rename failed: the install did not finish" in out
|
||||
assert "may already run the new name" in out
|
||||
|
||||
|
||||
def test_command_rename_validation_revert_reports_an_orphan(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("esphome:\n name: oldname\n")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
mock_run_external_process.return_value = 1
|
||||
with patch("pathlib.Path.unlink", side_effect=OSError("busy")):
|
||||
assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1
|
||||
out = capfd.readouterr().out
|
||||
assert "does not validate" in out and "Could not remove" in out
|
||||
@@ -42,7 +42,6 @@ from esphome.__main__ import (
|
||||
command_config_hash,
|
||||
command_dashboard,
|
||||
command_idedata,
|
||||
command_rename,
|
||||
command_run,
|
||||
command_update_all,
|
||||
command_wizard,
|
||||
@@ -101,7 +100,6 @@ from esphome.const import (
|
||||
CONF_PASSWORD,
|
||||
CONF_PLATFORM,
|
||||
CONF_PORT,
|
||||
CONF_SUBSTITUTIONS,
|
||||
CONF_TOPIC,
|
||||
CONF_USE_ADDRESS,
|
||||
CONF_USERNAME,
|
||||
@@ -4448,627 +4446,6 @@ def test_command_config_hash(
|
||||
assert output == f"0x{CORE.config_hash:08x}"
|
||||
|
||||
|
||||
def test_command_rename_invalid_characters(
|
||||
tmp_path: Path, capfd: CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Test command_rename with invalid characters in name."""
|
||||
setup_core(tmp_path=tmp_path)
|
||||
|
||||
# Test with invalid character (space)
|
||||
args = MockArgs(name="invalid name")
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
captured = capfd.readouterr()
|
||||
assert "invalid character" in captured.out.lower()
|
||||
|
||||
|
||||
def test_command_rename_complex_yaml(
|
||||
tmp_path: Path, capfd: CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Test command_rename with complex YAML that cannot be renamed."""
|
||||
config_file = tmp_path / "test.yaml"
|
||||
config_file.write_text("# Complex YAML without esphome section\nsome_key: value\n")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
|
||||
args = MockArgs(name="newname")
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
captured = capfd.readouterr()
|
||||
assert "complex yaml" in captured.out.lower()
|
||||
|
||||
|
||||
def test_command_rename_success(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test successful rename of a simple configuration."""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: oldname
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
|
||||
wifi:
|
||||
ssid: "test"
|
||||
password: "test1234"
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
|
||||
# Set up CORE.config to avoid ValueError when accessing CORE.address
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
|
||||
args = MockArgs(name="newname", dashboard=False)
|
||||
|
||||
# Simulate successful validation and upload
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
|
||||
# Verify new file was created
|
||||
new_file = tmp_path / "newname.yaml"
|
||||
assert new_file.exists()
|
||||
|
||||
# Verify old file was removed
|
||||
assert not config_file.exists()
|
||||
|
||||
# Verify content was updated
|
||||
content = new_file.read_text()
|
||||
assert (
|
||||
'name: "newname"' in content
|
||||
or "name: 'newname'" in content
|
||||
or "name: newname" in content
|
||||
)
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "SUCCESS" in captured.out
|
||||
|
||||
|
||||
def test_command_rename_with_substitutions(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename with substitutions in YAML."""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("""
|
||||
substitutions:
|
||||
device_name: oldname
|
||||
|
||||
esphome:
|
||||
name: ${device_name}
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
|
||||
# Set up CORE.config to avoid ValueError when accessing CORE.address
|
||||
CORE.config = {
|
||||
CONF_ESPHOME: {CONF_NAME: "oldname"},
|
||||
CONF_SUBSTITUTIONS: {"device_name": "oldname"},
|
||||
}
|
||||
|
||||
args = MockArgs(name="newname", dashboard=False)
|
||||
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
|
||||
# Verify substitution was updated
|
||||
new_file = tmp_path / "newname.yaml"
|
||||
content = new_file.read_text()
|
||||
assert 'device_name: "newname"' in content
|
||||
|
||||
|
||||
def test_command_rename_validation_failure(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename when validation fails."""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: oldname
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
|
||||
args = MockArgs(name="newname", dashboard=False)
|
||||
|
||||
# First call for validation fails
|
||||
mock_run_external_process.return_value = 1
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
|
||||
# Verify new file was created but then removed due to failure
|
||||
new_file = tmp_path / "newname.yaml"
|
||||
assert not new_file.exists()
|
||||
|
||||
# Verify old file still exists (not removed on failure)
|
||||
assert config_file.exists()
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "Rename failed" in captured.out
|
||||
|
||||
|
||||
def test_command_rename_install_failure_reverts(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename when the install (esphome run) step fails."""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: oldname
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
|
||||
args = MockArgs(name="newname", dashboard=False)
|
||||
|
||||
# First call (config validation) succeeds; second (esphome run) fails.
|
||||
mock_run_external_process.side_effect = [0, 1]
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
|
||||
# New file was unlinked when install failed.
|
||||
new_file = tmp_path / "newname.yaml"
|
||||
assert not new_file.exists()
|
||||
|
||||
# Old file is preserved so the device stays reachable under the
|
||||
# original hostname.
|
||||
assert config_file.exists()
|
||||
|
||||
|
||||
def test_command_rename_target_exists_refuses(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename refuses when the target filename already exists.
|
||||
|
||||
Without this guard, the rename would overwrite the unrelated
|
||||
device's YAML and OTA-install our firmware to the wrong device.
|
||||
"""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: oldname
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
target_file = tmp_path / "newname.yaml"
|
||||
target_file.write_text("""
|
||||
esphome:
|
||||
name: someoneelse
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
target_original = target_file.read_text()
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
|
||||
args = MockArgs(name="newname", dashboard=False)
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
# No subprocess work happened — refusal is up-front.
|
||||
mock_run_external_process.assert_not_called()
|
||||
# Target file untouched: same content, still on disk.
|
||||
assert target_file.exists()
|
||||
assert target_file.read_text() == target_original
|
||||
# Source file untouched.
|
||||
assert config_file.exists()
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "already exists" in captured.out
|
||||
|
||||
|
||||
def test_command_rename_same_name_refuses(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename refuses when the new name matches the current name.
|
||||
|
||||
A same-name rename would otherwise re-write the YAML and queue
|
||||
a redundant compile + install — wasted work the user almost
|
||||
certainly didn't intend.
|
||||
"""
|
||||
config_file = tmp_path / "samename.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: samename
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "samename"}}
|
||||
|
||||
args = MockArgs(name="samename", dashboard=False)
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
mock_run_external_process.assert_not_called()
|
||||
# File preserved verbatim — no rewrite happened.
|
||||
assert config_file.exists()
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "already" in captured.out.lower()
|
||||
|
||||
|
||||
def test_command_rename_does_not_touch_friendly_name_substring(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
r"""Test rename does not match the ``name:`` substring of ``friendly_name:``.
|
||||
|
||||
Without anchoring the regex at line start, the pattern
|
||||
``\s*name:\s+<old>`` could match the trailing ``name:``
|
||||
substring inside ``friendly_name: <old>``. The rewrite would
|
||||
flip both lines to the new name, leaving the user with a
|
||||
silently corrupted ``friendly_name``.
|
||||
"""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: oldname
|
||||
friendly_name: oldname
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
|
||||
args = MockArgs(name="newname", dashboard=False)
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
new_file = tmp_path / "newname.yaml"
|
||||
content = new_file.read_text()
|
||||
# esphome.name swapped.
|
||||
assert 'name: "newname"' in content
|
||||
# friendly_name kept verbatim.
|
||||
assert "friendly_name: oldname" in content
|
||||
|
||||
|
||||
def test_command_rename_does_not_match_old_name_as_value_prefix(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
r"""Test rename does not match ``old_name`` as a prefix of a longer value.
|
||||
|
||||
With ``old_name = kitchen`` the value ``kitchen2`` (a sensor
|
||||
or wifi entry) would otherwise match the unanchored
|
||||
``["']?kitchen["']?`` pattern at the prefix and get
|
||||
rewritten to the new name. The end-of-value lookahead keeps
|
||||
the match restricted to whole tokens.
|
||||
"""
|
||||
config_file = tmp_path / "kitchen.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: kitchen
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
|
||||
wifi:
|
||||
ap:
|
||||
ssid: kitchen2
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
|
||||
|
||||
args = MockArgs(name="garage", dashboard=False)
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
new_file = tmp_path / "garage.yaml"
|
||||
content = new_file.read_text()
|
||||
assert 'name: "garage"' in content
|
||||
# The wifi ssid value is unrelated and stays intact.
|
||||
assert "ssid: kitchen2" in content
|
||||
|
||||
|
||||
def test_command_rename_same_resolved_name_refuses(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename refuses when ``new_name`` matches the resolved device name.
|
||||
|
||||
The path-equality check only catches the case where the
|
||||
config filename matches the device name. For a config whose
|
||||
filename and ``esphome.name`` differ (here ``weird-file.yaml``
|
||||
holds ``esphome.name: kitchen``), running
|
||||
``esphome rename weird-file.yaml kitchen`` would otherwise
|
||||
fall through to the rewrite + install: the YAML's name stays
|
||||
``kitchen``, the file is renamed to ``kitchen.yaml``, and the
|
||||
device gets a redundant flash. Refuse up-front so the
|
||||
"already the device's name" message matches reality.
|
||||
"""
|
||||
config_file = tmp_path / "weird-file.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: kitchen
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
|
||||
|
||||
args = MockArgs(name="kitchen", dashboard=False)
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
mock_run_external_process.assert_not_called()
|
||||
# Source file untouched, no derived target written.
|
||||
assert config_file.exists()
|
||||
assert not (tmp_path / "kitchen.yaml").exists()
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "already" in captured.out.lower()
|
||||
|
||||
|
||||
def test_command_rename_target_path_equals_source_refuses(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename refuses when the new path resolves to the source file.
|
||||
|
||||
Reachable only when the YAML's filename and ``esphome.name``
|
||||
disagree — here ``kitchen.yaml`` holds ``esphome.name: garage``
|
||||
and the user runs ``esphome rename kitchen.yaml kitchen``. The
|
||||
name-equality check above passes (``garage != kitchen``), but
|
||||
``<config_dir>/kitchen.yaml`` resolves to the source file
|
||||
itself, so the rewrite would clobber the source mid-rename.
|
||||
Refuse rather than silently overwriting.
|
||||
"""
|
||||
config_file = tmp_path / "kitchen.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: garage
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "garage"}}
|
||||
|
||||
args = MockArgs(name="kitchen", dashboard=False)
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
mock_run_external_process.assert_not_called()
|
||||
# Source file still present and unmodified.
|
||||
assert config_file.exists()
|
||||
assert "name: garage" in config_file.read_text()
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "already" in captured.out.lower()
|
||||
|
||||
|
||||
def test_command_rename_does_not_touch_lookalike_name_in_other_blocks(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename only swaps the esphome.name line.
|
||||
|
||||
A device whose name happens to match a sensor's / output's
|
||||
``name:`` value must not have those other names rewritten —
|
||||
they're independent. Without an anchor for the esphome block
|
||||
a naive regex would clobber every line whose value matches.
|
||||
"""
|
||||
config_file = tmp_path / "kitchen.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: kitchen
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: kitchen
|
||||
lambda: 'return 0;'
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
|
||||
|
||||
args = MockArgs(name="garage", dashboard=False)
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
|
||||
new_file = tmp_path / "garage.yaml"
|
||||
content = new_file.read_text()
|
||||
# esphome.name renamed.
|
||||
assert 'name: "garage"' in content
|
||||
# Sensor's name is the user's entity name — must not be touched.
|
||||
assert " name: kitchen\n" in content
|
||||
|
||||
|
||||
def test_command_rename_preserves_trailing_comment(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename preserves a trailing ``# comment`` on the name line."""
|
||||
config_file = tmp_path / "kitchen.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: kitchen # primary device
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
|
||||
|
||||
args = MockArgs(name="garage", dashboard=False)
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
|
||||
new_file = tmp_path / "garage.yaml"
|
||||
content = new_file.read_text()
|
||||
assert "# primary device" in content
|
||||
|
||||
|
||||
def test_command_rename_handles_double_quoted_value(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename matches when the existing value is double-quoted."""
|
||||
config_file = tmp_path / "kitchen.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: "kitchen"
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
|
||||
|
||||
args = MockArgs(name="garage", dashboard=False)
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
new_file = tmp_path / "garage.yaml"
|
||||
assert 'name: "garage"' in new_file.read_text()
|
||||
|
||||
|
||||
def test_command_rename_handles_single_quoted_value(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename matches when the existing value is single-quoted."""
|
||||
config_file = tmp_path / "kitchen.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: 'kitchen'
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
|
||||
|
||||
args = MockArgs(name="garage", dashboard=False)
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
new_file = tmp_path / "garage.yaml"
|
||||
assert 'name: "garage"' in new_file.read_text()
|
||||
|
||||
|
||||
def test_command_rename_too_many_substitution_matches_refuses(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename refuses when ``${var}`` resolves to multiple matches.
|
||||
|
||||
When ``esphome.name: ${device_name}`` and the substitution
|
||||
definition ``device_name: foo`` appears more than once in the
|
||||
YAML (e.g. inside multiple included blocks), the regex rewrite
|
||||
can't tell which one to flip. Rather than silently picking one
|
||||
or rewriting both, the command refuses.
|
||||
"""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("""
|
||||
substitutions:
|
||||
device_name: oldname
|
||||
|
||||
esphome:
|
||||
name: ${device_name}
|
||||
|
||||
# A copy-pasted block that re-declares the substitution at the
|
||||
# same indent level - happens when users splice in a packaged
|
||||
# fragment without renaming the variable.
|
||||
example:
|
||||
device_name: oldname
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {
|
||||
CONF_ESPHOME: {CONF_NAME: "oldname"},
|
||||
CONF_SUBSTITUTIONS: {"device_name": "oldname"},
|
||||
}
|
||||
|
||||
args = MockArgs(name="newname", dashboard=False)
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
mock_run_external_process.assert_not_called()
|
||||
# File untouched.
|
||||
assert config_file.exists()
|
||||
assert "device_name: oldname" in config_file.read_text()
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "Too many matches" in captured.out
|
||||
|
||||
|
||||
def test_command_update_all_path_string_conversion(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
@@ -8028,3 +7405,11 @@ def test_command_analyze_memory_host_refuses_before_compiling(
|
||||
command_analyze_memory(MockArgs(), {})
|
||||
mock_write_cpp.assert_not_called()
|
||||
mock_compile_program.assert_not_called()
|
||||
|
||||
|
||||
def test_command_rename_is_dispatched_to_the_cli_module() -> None:
|
||||
"""__main__ keeps a thin wrapper and imports the command when it runs."""
|
||||
args = MockArgs(name="newname")
|
||||
with patch("esphome.cli.rename.command_rename", return_value=7) as run:
|
||||
assert main.command_rename(args, {}) == 7
|
||||
run.assert_called_once_with(args, {})
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Tests for rewriting single lines of a yaml file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import yaml_util
|
||||
from esphome.config import do_substitution_pass
|
||||
from esphome.const import CONF_ESPHOME, CONF_NAME
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.yaml_edit import (
|
||||
LineEdit,
|
||||
field_line_re,
|
||||
line_at,
|
||||
read_text,
|
||||
rewrite,
|
||||
rewritten_text,
|
||||
source_of,
|
||||
write_keeping_mode,
|
||||
)
|
||||
|
||||
YAML = """esphome:
|
||||
name: kitchen # the device
|
||||
|
||||
wifi:
|
||||
ssid: kitchen
|
||||
"""
|
||||
|
||||
|
||||
def _setup(tmp_path: Path, yaml_text: str) -> Path:
|
||||
"""Write the yaml, point CORE at it and load it the way read_config does,
|
||||
so every node carries its source range."""
|
||||
CORE.reset()
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
# Bytes, so Windows does not turn the newlines into CRLF on the way in
|
||||
CORE.config_path.write_bytes(yaml_text.encode())
|
||||
CORE.raw_config = do_substitution_pass(yaml_util.load_yaml(CORE.config_path), None)
|
||||
return CORE.config_path
|
||||
|
||||
|
||||
def _name_edit(new_name: str) -> LineEdit:
|
||||
doc, line_no = source_of(CORE.raw_config[CONF_ESPHOME], CONF_NAME)
|
||||
text = line_at(doc, line_no)
|
||||
match = field_line_re(CONF_NAME, "kitchen").match(text)
|
||||
return LineEdit(doc, line_no, text, rewrite(match, new_name))
|
||||
|
||||
|
||||
def test_rewrite_keeps_the_rest_of_the_line(tmp_path: Path) -> None:
|
||||
"""The value changes; indentation, quotes and the comment stay."""
|
||||
path = _setup(tmp_path, YAML.replace("name: kitchen", "name: 'kitchen'"))
|
||||
edit = _name_edit("garage")
|
||||
assert (edit.line, edit.new_line) == (1, " name: 'garage' # the device")
|
||||
assert rewritten_text(read_text(path), [edit]) == YAML.replace(
|
||||
"name: kitchen", "name: 'garage'"
|
||||
)
|
||||
|
||||
|
||||
def test_rewrite_can_force_quotes() -> None:
|
||||
match = field_line_re(CONF_NAME, "kitchen").match(" name: kitchen # x")
|
||||
assert rewrite(match, "garage", quote='"') == ' name: "garage" # x'
|
||||
|
||||
|
||||
def test_only_the_located_line_changes(tmp_path: Path) -> None:
|
||||
"""A lookalike `name:` under another block has its own range."""
|
||||
yaml_text = YAML + "sensor:\n - platform: template\n name: kitchen\n"
|
||||
path = _setup(tmp_path, yaml_text)
|
||||
text = rewritten_text(read_text(path), [_name_edit("garage")])
|
||||
assert text.endswith(" name: kitchen\n")
|
||||
assert " name: garage # the device" in text
|
||||
|
||||
|
||||
def test_line_endings_are_kept(tmp_path: Path) -> None:
|
||||
path = _setup(tmp_path, YAML.replace("\n", "\r\n"))
|
||||
assert rewritten_text(read_text(path), [_name_edit("garage")]) == YAML.replace(
|
||||
"\n", "\r\n"
|
||||
).replace("name: kitchen", "name: garage")
|
||||
|
||||
|
||||
def test_stale_line_is_refused(tmp_path: Path) -> None:
|
||||
path = _setup(tmp_path, YAML)
|
||||
edit = _name_edit("garage")
|
||||
with pytest.raises(EsphomeError, match="changed since it was read"):
|
||||
rewritten_text(YAML.replace("kitchen #", "pantry #"), [edit])
|
||||
with pytest.raises(EsphomeError, match="changed since it was read"):
|
||||
rewritten_text("esphome:\n", [edit])
|
||||
with pytest.raises(EsphomeError, match="changed since it was read"):
|
||||
line_at(path, 5)
|
||||
|
||||
|
||||
def test_a_comment_needs_whitespace_and_a_scalar_is_not_empty() -> None:
|
||||
"""`abc#def` is one value to the loader, and a bare `key:` heads a block."""
|
||||
assert field_line_re("key", "abc").match("key: abc#def") is None
|
||||
assert field_line_re("key").match("key:") is None
|
||||
assert field_line_re("key").match("key: abc # c")["trail"] == " # c"
|
||||
assert field_line_re("key", "abc#def").match("key: abc#def") is not None
|
||||
|
||||
|
||||
def test_source_of_is_none_for_a_value_validation_added(tmp_path: Path) -> None:
|
||||
"""Only a key read from a file carries a range, and only a mapping read
|
||||
from a file can place its keys; a mapping built in code cannot."""
|
||||
assert source_of({"name": "kitchen"}, "name") is None
|
||||
_setup(tmp_path, YAML)
|
||||
loaded_key = next(iter(CORE.raw_config[CONF_ESPHOME]))
|
||||
assert source_of({loaded_key: "kitchen"}, CONF_NAME) is None
|
||||
|
||||
|
||||
def test_source_of_is_none_for_a_merged_key(tmp_path: Path) -> None:
|
||||
"""A key a merge brought in points at the anchor, which other mappings
|
||||
may merge as well; it is not this mapping's own line."""
|
||||
_setup(
|
||||
tmp_path,
|
||||
"named: &named\n name: kitchen\n\nesphome:\n <<: *named\n friendly_name: x\n",
|
||||
)
|
||||
assert source_of(CORE.raw_config[CONF_ESPHOME], CONF_NAME) is None
|
||||
assert source_of(CORE.raw_config[CONF_ESPHOME], "friendly_name") == (
|
||||
tmp_path / "test.yaml",
|
||||
5,
|
||||
)
|
||||
|
||||
|
||||
def test_source_of_names_the_file_the_loader_read(tmp_path: Path) -> None:
|
||||
"""An include has its own document; a symlink is reported as given."""
|
||||
(tmp_path / "base.yaml").write_bytes(b"name: kitchen\n")
|
||||
_setup(tmp_path, "esphome: !include base.yaml\n")
|
||||
assert source_of(CORE.raw_config[CONF_ESPHOME], CONF_NAME) == (
|
||||
tmp_path / "base.yaml",
|
||||
0,
|
||||
)
|
||||
target = tmp_path / "shared" / "test.yaml"
|
||||
target.parent.mkdir()
|
||||
target.write_bytes(YAML.encode())
|
||||
CORE.config_path.unlink()
|
||||
CORE.config_path.symlink_to(target)
|
||||
CORE.raw_config = yaml_util.load_yaml(CORE.config_path)
|
||||
assert _name_edit("garage").path == CORE.config_path
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="posix file modes")
|
||||
def test_write_keeps_the_mode_of_the_file_or_another(tmp_path: Path) -> None:
|
||||
path = _setup(tmp_path, YAML)
|
||||
path.chmod(0o600)
|
||||
write_keeping_mode(path, YAML)
|
||||
assert path.stat().st_mode & 0o777 == 0o600
|
||||
other = tmp_path / "other.yaml"
|
||||
write_keeping_mode(other, YAML, like=path)
|
||||
assert other.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_write_failures_say_which_step_and_why(tmp_path: Path) -> None:
|
||||
"""A missing mode source, a write that fails, and a mode that cannot be
|
||||
put back after the write are three different situations."""
|
||||
from unittest.mock import patch
|
||||
|
||||
path = _setup(tmp_path, YAML)
|
||||
with pytest.raises(EsphomeError, match="Could not read the mode of .*gone.yaml"):
|
||||
write_keeping_mode(path, YAML, like=tmp_path / "gone.yaml")
|
||||
with (
|
||||
patch("pathlib.Path.chmod", side_effect=OSError("denied")),
|
||||
pytest.raises(
|
||||
EsphomeError, match="was written but could not get its mode back: denied"
|
||||
),
|
||||
):
|
||||
write_keeping_mode(path, YAML)
|
||||
|
||||
def refuse(*_args: object, **_kwargs: object) -> None:
|
||||
raise EsphomeError(f"Could not write file at {path}") from OSError("disk full")
|
||||
|
||||
with (
|
||||
patch("esphome.yaml_edit.write_file", side_effect=refuse),
|
||||
pytest.raises(EsphomeError, match="Could not write file at .*: disk full"),
|
||||
):
|
||||
write_keeping_mode(path, YAML)
|
||||
|
||||
|
||||
def test_read_text_reports_a_file_it_cannot_decode(tmp_path: Path) -> None:
|
||||
path = tmp_path / "latin1.yaml"
|
||||
path.write_bytes(b"caf\xe9: 1\n")
|
||||
with pytest.raises(EsphomeError, match="Error reading file"):
|
||||
read_text(path)
|
||||
Reference in New Issue
Block a user