diff --git a/esphome/__main__.py b/esphome/__main__.py index cc1e12cb3a..c4ba6b54d7 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2509,6 +2509,49 @@ def parse_args(argv): return parser.parse_args(arguments) +def _warn_if_source_tree_mismatch() -> None: + """Warn when the checkout the user is standing in is not the one being run. + + An editable install records one absolute path, so a venv shared between git + worktrees (or reused after a checkout is copied or renamed) keeps importing + the tree it was installed from. Every command then silently runs, and + compiles, sources the user is not looking at. Only fires inside a checkout, + so ordinary installs never see it. + """ + try: + cwd = Path.cwd() + except OSError: + return # working directory is gone; a diagnostic must not break startup + for candidate in (cwd, *cwd.parents): + if (candidate / "esphome" / "__main__.py").is_file(): + standing_in = candidate.resolve() + break + else: + return # not inside a checkout; nothing to compare against + + running = Path(__file__).resolve().parent.parent + # Both sides are resolved, so on a case-sensitive filesystem this matches + # plain equality. samefile() compares device and inode, which additionally + # covers a case-insensitive filesystem (macOS) reaching one directory by + # differently cased paths. Falls back to equality if either path is gone. + try: + same = standing_in.samefile(running) + except OSError: + same = standing_in == running + if same: + return + + _LOGGER.warning( + "Running ESPHome from a different checkout than the one you are in:\n" + " running from: %s\n" + " you are in: %s\n" + "The installed esphome resolves to the first, so its sources are used.\n" + "Run 'python -m esphome' from the second to use that one instead.", + running, + standing_in, + ) + + def run_esphome(argv): from esphome.address_cache import AddressCache @@ -2527,6 +2570,7 @@ def run_esphome(argv): args.log_level = "CRITICAL" setup_log(log_level=args.log_level) + _warn_if_source_tree_mismatch() if args.command in PRE_CONFIG_ACTIONS: try: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 6c13cd5f12..14b49a1a05 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -18,6 +18,7 @@ import pytest from pytest import CaptureFixture from zeroconf import ServiceStateChange +from esphome import __main__ as main from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, @@ -6760,3 +6761,144 @@ def test_check_permissions_unreadable_port() -> None: pytest.raises(EsphomeError, match="read or write permission"), ): check_permissions("/dev/ttyUSB99") + + +def _make_checkout(root: Path) -> Path: + """Create a directory that looks like an esphome checkout.""" + (root / "esphome").mkdir(parents=True) + (root / "esphome" / "__main__.py").write_text("", encoding="utf-8") + return root + + +def test_warn_source_tree_mismatch_warns_for_other_tree( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Standing in a checkout other than the one being run warns.""" + standing_in = _make_checkout(tmp_path / "worktree") + running = _make_checkout(tmp_path / "main") + monkeypatch.chdir(standing_in) + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert "worktree" in caplog.text + assert "main" in caplog.text + + +def test_warn_source_tree_mismatch_silent_in_same_tree( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Standing in the tree that is running is the normal case and is silent.""" + tree = _make_checkout(tmp_path / "main") + monkeypatch.chdir(tree) + monkeypatch.setattr(main, "__file__", str(tree / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_silent_outside_checkout( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """An ordinary install run from a config directory never warns.""" + running = _make_checkout(tmp_path / "main") + config_dir = tmp_path / "configs" + config_dir.mkdir() + monkeypatch.chdir(config_dir) + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_silent_in_subdirectory( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A subdirectory of the running tree resolves to that tree, so no warning.""" + tree = _make_checkout(tmp_path / "main") + subdir = tree / "esphome" / "components" + subdir.mkdir(parents=True) + monkeypatch.chdir(subdir) + monkeypatch.setattr(main, "__file__", str(tree / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_warns_when_stat_fails_on_other_tree( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """The samefile() fallback must still warn when the trees really differ.""" + standing_in = _make_checkout(tmp_path / "worktree") + running = _make_checkout(tmp_path / "main") + monkeypatch.chdir(standing_in) + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + def raise_oserror(self: Path, other: Path) -> bool: + raise OSError("stat failed") + + monkeypatch.setattr(Path, "samefile", raise_oserror) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert "worktree" in caplog.text + + +def test_warn_source_tree_mismatch_silent_when_cwd_is_gone( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A deleted working directory must not turn the diagnostic into a traceback.""" + running = _make_checkout(tmp_path / "main") + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + def raise_filenotfound() -> Path: + raise FileNotFoundError("cwd is gone") + + monkeypatch.setattr(Path, "cwd", staticmethod(raise_filenotfound)) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_falls_back_when_stat_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """If samefile() cannot stat, fall back to comparing the paths.""" + tree = _make_checkout(tmp_path / "main") + monkeypatch.chdir(tree) + monkeypatch.setattr(main, "__file__", str(tree / "esphome" / "__main__.py")) + + def raise_oserror(self: Path, other: Path) -> bool: + raise OSError("stat failed") + + monkeypatch.setattr(Path, "samefile", raise_oserror) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + # Same tree, so the path comparison still finds them equal and stays silent + assert not caplog.text