1
0
mirror of https://github.com/esphome/esphome.git synced 2025-10-17 16:07:12 +02:00

NAMED exceptions

This commit is contained in:
J. Nick Koston 2025-10-14 17:10:46 -10:00
parent 471bb93f29
commit 55b890f152
No known key found for this signature in database
2 changed files with 36 additions and 16 deletions

View File

@ -18,6 +18,22 @@ _LOGGER = logging.getLogger(__name__)
NEVER_REFRESH = TimePeriodSeconds(seconds=-1) NEVER_REFRESH = TimePeriodSeconds(seconds=-1)
class GitException(cv.Invalid):
"""Base exception for git-related errors."""
class GitNotInstalledError(GitException):
"""Exception raised when git is not installed on the system."""
class GitCommandError(GitException):
"""Exception raised when a git command fails."""
class GitRepositoryError(GitException):
"""Exception raised when a git repository is in an invalid state."""
def run_git_command( def run_git_command(
cmd: list[str], cwd: str | None = None, git_dir: Path | None = None cmd: list[str], cwd: str | None = None, git_dir: Path | None = None
) -> str: ) -> str:
@ -33,8 +49,12 @@ def run_git_command(
try: try:
env = None env = None
if git_dir is not None: if git_dir is not None:
# Force git to only operate on this specific repository # Force git to only operate on this specific repository by setting
# This prevents git from walking up to parent repositories # GIT_DIR and GIT_WORK_TREE. This prevents git from walking up the
# directory tree to find parent repositories when the target repo's
# .git directory is corrupt. Without this, commands like 'git stash'
# could accidentally operate on parent repositories (e.g., the main
# ESPHome repo) instead of failing, causing data loss.
env = { env = {
**subprocess.os.environ, **subprocess.os.environ,
"GIT_DIR": str(Path(git_dir) / ".git"), "GIT_DIR": str(Path(git_dir) / ".git"),
@ -44,7 +64,7 @@ def run_git_command(
cmd, cwd=cwd, capture_output=True, check=False, close_fds=False, env=env cmd, cwd=cwd, capture_output=True, check=False, close_fds=False, env=env
) )
except FileNotFoundError as err: except FileNotFoundError as err:
raise cv.Invalid( raise GitNotInstalledError(
"git is not installed but required for external_components.\n" "git is not installed but required for external_components.\n"
"Please see https://git-scm.com/book/en/v2/Getting-Started-Installing-Git for installing git" "Please see https://git-scm.com/book/en/v2/Getting-Started-Installing-Git for installing git"
) from err ) from err
@ -53,8 +73,8 @@ def run_git_command(
err_str = ret.stderr.decode("utf-8") err_str = ret.stderr.decode("utf-8")
lines = [x.strip() for x in err_str.splitlines()] lines = [x.strip() for x in err_str.splitlines()]
if lines[-1].startswith("fatal:"): if lines[-1].startswith("fatal:"):
raise cv.Invalid(lines[-1][len("fatal: ") :]) raise GitCommandError(lines[-1][len("fatal: ") :])
raise cv.Invalid(err_str) raise GitCommandError(err_str)
return ret.stdout.decode("utf-8").strip() return ret.stdout.decode("utf-8").strip()
@ -152,7 +172,7 @@ def clone_or_update(
str(repo_dir), str(repo_dir),
git_dir=repo_dir, git_dir=repo_dir,
) )
except cv.Invalid as err: except GitException as err:
# Repository is in a broken state or update failed # Repository is in a broken state or update failed
# Only attempt recovery once to prevent infinite recursion # Only attempt recovery once to prevent infinite recursion
if not _recover_broken: if not _recover_broken:

View File

@ -9,8 +9,8 @@ from unittest.mock import Mock
import pytest import pytest
from esphome import git from esphome import git
import esphome.config_validation as cv
from esphome.core import CORE, TimePeriodSeconds from esphome.core import CORE, TimePeriodSeconds
from esphome.git import GitCommandError
def _compute_repo_dir(url: str, ref: str | None, domain: str) -> Path: def _compute_repo_dir(url: str, ref: str | None, domain: str) -> Path:
@ -305,7 +305,7 @@ def test_clone_or_update_recovers_from_git_failures(
# Fail on first call to the specified command, succeed on subsequent calls # Fail on first call to the specified command, succeed on subsequent calls
if cmd_type == fail_command and call_counts[cmd_type] == 1: if cmd_type == fail_command and call_counts[cmd_type] == 1:
raise cv.Invalid(error_message) raise GitCommandError(error_message)
# Default successful responses # Default successful responses
if cmd_type == "rev-parse": if cmd_type == "rev-parse":
@ -357,12 +357,12 @@ def test_clone_or_update_fails_when_recovery_also_fails(
cmd_type = _get_git_command_type(cmd) cmd_type = _get_git_command_type(cmd)
if cmd_type == "rev-parse": if cmd_type == "rev-parse":
# First time fails (broken repo) # First time fails (broken repo)
raise cv.Invalid( raise GitCommandError(
"ambiguous argument 'HEAD': unknown revision or path not in the working tree." "ambiguous argument 'HEAD': unknown revision or path not in the working tree."
) )
if cmd_type == "clone": if cmd_type == "clone":
# Clone also fails (recovery fails) # Clone also fails (recovery fails)
raise cv.Invalid("fatal: unable to access repository") raise GitCommandError("fatal: unable to access repository")
return "" return ""
mock_run_git_command.side_effect = git_command_side_effect mock_run_git_command.side_effect = git_command_side_effect
@ -370,7 +370,7 @@ def test_clone_or_update_fails_when_recovery_also_fails(
refresh = TimePeriodSeconds(days=1) refresh = TimePeriodSeconds(days=1)
# Should raise after one recovery attempt fails # Should raise after one recovery attempt fails
with pytest.raises(cv.Invalid, match="fatal: unable to access repository"): with pytest.raises(GitCommandError, match="fatal: unable to access repository"):
git.clone_or_update( git.clone_or_update(
url=url, url=url,
ref=ref, ref=ref,
@ -417,7 +417,7 @@ def test_clone_or_update_recover_broken_flag_prevents_second_recovery(
# First attempt: rev-parse fails (broken repo) # First attempt: rev-parse fails (broken repo)
if cmd_type == "rev-parse" and call_counts[cmd_type] == 1: if cmd_type == "rev-parse" and call_counts[cmd_type] == 1:
raise cv.Invalid( raise GitCommandError(
"ambiguous argument 'HEAD': unknown revision or path not in the working tree." "ambiguous argument 'HEAD': unknown revision or path not in the working tree."
) )
@ -428,7 +428,7 @@ def test_clone_or_update_recover_broken_flag_prevents_second_recovery(
# Recovery: fetch for ref checkout fails # Recovery: fetch for ref checkout fails
# This happens in the clone path when ref is not None (line 80 in git.py) # This happens in the clone path when ref is not None (line 80 in git.py)
if cmd_type == "fetch" and call_counts[cmd_type] == 1: if cmd_type == "fetch" and call_counts[cmd_type] == 1:
raise cv.Invalid("fatal: couldn't find remote ref main") raise GitCommandError("fatal: couldn't find remote ref main")
# Default success # Default success
return "abc123" if cmd_type == "rev-parse" else "" return "abc123" if cmd_type == "rev-parse" else ""
@ -439,7 +439,7 @@ def test_clone_or_update_recover_broken_flag_prevents_second_recovery(
# Should raise on the fetch during recovery (when _recover_broken=False) # Should raise on the fetch during recovery (when _recover_broken=False)
# This tests the critical "if not _recover_broken: raise" path # This tests the critical "if not _recover_broken: raise" path
with pytest.raises(cv.Invalid, match="fatal: couldn't find remote ref main"): with pytest.raises(GitCommandError, match="fatal: couldn't find remote ref main"):
git.clone_or_update( git.clone_or_update(
url=url, url=url,
ref=ref, ref=ref,
@ -498,7 +498,7 @@ def test_clone_or_update_recover_broken_flag_prevents_infinite_loop(
return "abc123" return "abc123"
if cmd_type == "stash": if cmd_type == "stash":
# Always fails # Always fails
raise cv.Invalid("fatal: unable to write new index file") raise GitCommandError("fatal: unable to write new index file")
return "" return ""
mock_run_git_command.side_effect = git_command_side_effect mock_run_git_command.side_effect = git_command_side_effect
@ -510,7 +510,7 @@ def test_clone_or_update_recover_broken_flag_prevents_infinite_loop(
# This hits the "if not _recover_broken: raise" path # This hits the "if not _recover_broken: raise" path
with ( with (
unittest.mock.patch("esphome.git.shutil.rmtree", side_effect=mock_rmtree), unittest.mock.patch("esphome.git.shutil.rmtree", side_effect=mock_rmtree),
pytest.raises(cv.Invalid, match="fatal: unable to write new index file"), pytest.raises(GitCommandError, match="fatal: unable to write new index file"),
): ):
git.clone_or_update( git.clone_or_update(
url=url, url=url,