mirror of
https://github.com/esphome/esphome.git
synced 2025-10-17 16:07:12 +02:00
NAMED exceptions
This commit is contained in:
parent
471bb93f29
commit
55b890f152
@ -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:
|
||||||
|
@ -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,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user