[espidf] Support github:// and https://github.com/.../.git framework sources (#16639)

This commit is contained in:
Jonathan Swoboda
2026-05-25 17:07:35 -04:00
committed by GitHub
parent 0b780f1fd2
commit fc0a4e2201
3 changed files with 256 additions and 20 deletions

View File

@@ -8,6 +8,7 @@ import logging
import os import os
from pathlib import Path from pathlib import Path
import platform import platform
import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
@@ -784,6 +785,77 @@ def download_from_mirrors(
return None return None
_GITHUB_SHORTHAND_RE = re.compile(
r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:@([a-zA-Z0-9\-_.\./]+))?$"
)
_GITHUB_HTTPS_RE = re.compile(
r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:@([a-zA-Z0-9\-_.\./]+))?$"
)
def _parse_git_source(source_url: str) -> tuple[str, str | None] | None:
"""Return ``(url, ref)`` for ``github://owner/repo[@ref]`` or
``https://github.com/owner/repo.git[@ref]``, else ``None``."""
if m := _GITHUB_SHORTHAND_RE.match(source_url):
owner, repo, ref = m.group(1), m.group(2), m.group(3)
# Tolerate a trailing ".git" on the shorthand repo so the
# github://owner/repo.git form doesn't silently become repo.git.git.
repo = repo.removesuffix(".git")
return f"https://github.com/{owner}/{repo}.git", ref
if m := _GITHUB_HTTPS_RE.match(source_url):
return m.group(1), m.group(2)
return None
def _clone_idf_with_submodules(
framework_path: Path, git_url: str, ref: str | None
) -> None:
"""Shallow-clone ESP-IDF with submodules into ``framework_path``.
GitHub's archive zip strips submodules, so vendored components
(mbedtls, openthread, esptool, ...) come down empty and CMake fails.
Uses clone + ``fetch FETCH_HEAD`` + ``reset --hard`` instead of
``--branch``: ``--branch`` only accepts branch or tag names, but a
user can also point at a commit SHA. The fetch-then-reset pattern
handles branches, tags, and SHAs uniformly (mirrors the approach in
``esphome.git.clone_or_update``).
"""
from esphome.git import run_git_command
_LOGGER.info("Cloning ESP-IDF from %s%s", git_url, f"@{ref}" if ref else "")
run_git_command(["git", "clone", "--depth=1", "--", git_url, str(framework_path)])
if ref:
run_git_command(
["git", "fetch", "--depth=1", "--", "origin", ref],
git_dir=framework_path,
)
run_git_command(
["git", "reset", "--hard", "FETCH_HEAD"],
git_dir=framework_path,
)
run_git_command(
[
"git",
"submodule",
"update",
"--init",
"--recursive",
"--depth=1",
],
git_dir=framework_path,
)
# Sanity-check the resulting tree. run_git_command only raises when
# stderr is non-empty, so a clone that silently produces no working
# tree would otherwise be marked extracted and stuck until
# ``esphome clean``.
if not (framework_path / "tools" / "idf_tools.py").is_file():
raise RuntimeError(
f"Clone of {git_url} produced no usable ESP-IDF tree at {framework_path}"
)
def _write_idf_version_txt(framework_path: Path, version: str) -> None: def _write_idf_version_txt(framework_path: Path, version: str) -> None:
"""Write <framework_path>/version.txt if missing. """Write <framework_path>/version.txt if missing.
@@ -939,27 +1011,34 @@ def _check_esphome_idf_framework_install(
if install: if install:
rmdir(framework_path, msg=f"Clean up ESP-IDF {version} framework") rmdir(framework_path, msg=f"Clean up ESP-IDF {version} framework")
# Download in temporary file git_source = _parse_git_source(source_url) if source_url else None
with tempfile.NamedTemporaryFile() as tmp: if git_source is not None:
_LOGGER.info("Downloading ESP-IDF %s framework ...", version) git_url, ref = git_source
_clone_idf_with_submodules(framework_path, git_url, ref)
else:
# Download in temporary file
with tempfile.NamedTemporaryFile() as tmp:
_LOGGER.info("Downloading ESP-IDF %s framework ...", version)
# Create substitutions for the URLs # Create substitutions for the URLs
substitutions = {"VERSION": version} substitutions = {"VERSION": version}
try: try:
ver = Version.parse(version) ver = Version.parse(version)
substitutions["MAJOR"] = str(ver.major) substitutions["MAJOR"] = str(ver.major)
substitutions["MINOR"] = str(ver.minor) substitutions["MINOR"] = str(ver.minor)
substitutions["PATCH"] = str(ver.patch) substitutions["PATCH"] = str(ver.patch)
substitutions["EXTRA"] = ver.extra substitutions["EXTRA"] = ver.extra
except ValueError: except ValueError:
pass pass
mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS
download_from_mirrors(mirrors, substitutions, tmp.file) download_from_mirrors(mirrors, substitutions, tmp.file)
_LOGGER.info("Extracting ESP-IDF %s framework ...", version) _LOGGER.info("Extracting ESP-IDF %s framework ...", version)
archive_extract_all(tmp.file, framework_path, progress_header="Extracting") archive_extract_all(
extracted_marker.touch() tmp.file, framework_path, progress_header="Extracting"
)
extracted_marker.touch()
# Idempotent post-extract patch: written every invocation so a build # Idempotent post-extract patch: written every invocation so a build
# dir extracted before this fix gets the file too, without forcing a # dir extracted before this fix gets the file too, without forcing a

View File

@@ -72,8 +72,9 @@ def run_git_command(cmd: list[str], git_dir: Path | None = None) -> str:
) )
except FileNotFoundError as err: except FileNotFoundError as err:
raise GitNotInstalledError( raise GitNotInstalledError(
"git is not installed but required for external_components.\n" "git is not installed. See "
"Please see https://git-scm.com/book/en/v2/Getting-Started-Installing-Git for installing git" "https://git-scm.com/book/en/v2/Getting-Started-Installing-Git "
"for installation instructions."
) from err ) from err
if ret.returncode != 0 and ret.stderr: if ret.returncode != 0 and ret.stderr:

View File

@@ -0,0 +1,156 @@
"""Tests for esphome.espidf.framework helpers."""
# pylint: disable=protected-access
from pathlib import Path
from unittest.mock import patch
import pytest
from esphome.espidf.framework import _clone_idf_with_submodules, _parse_git_source
@pytest.mark.parametrize(
("source", "expected"),
[
# github:// shorthand
(
"github://espressif/esp-idf",
("https://github.com/espressif/esp-idf.git", None),
),
(
"github://espressif/esp-idf@master",
("https://github.com/espressif/esp-idf.git", "master"),
),
(
"github://espressif/esp-idf@release/v6.0",
("https://github.com/espressif/esp-idf.git", "release/v6.0"),
),
# explicit https://github.com/...git URL
(
"https://github.com/espressif/esp-idf.git",
("https://github.com/espressif/esp-idf.git", None),
),
(
"https://github.com/espressif/esp-idf.git@master",
("https://github.com/espressif/esp-idf.git", "master"),
),
(
"https://github.com/espressif/esp-idf.git@v6.0.1",
("https://github.com/espressif/esp-idf.git", "v6.0.1"),
),
# Tolerate a trailing ".git" on the shorthand so the user doesn't
# silently end up with a doubled "...esp-idf.git.git" URL.
(
"github://espressif/esp-idf.git",
("https://github.com/espressif/esp-idf.git", None),
),
(
"github://espressif/esp-idf.git@master",
("https://github.com/espressif/esp-idf.git", "master"),
),
],
)
def test_parse_git_source_recognized(
source: str, expected: tuple[str, str | None]
) -> None:
assert _parse_git_source(source) == expected
@pytest.mark.parametrize(
"source",
[
# archive URLs fall through to the existing download path
"https://github.com/espressif/esp-idf/archive/refs/heads/master.zip",
"https://dl.espressif.com/dl/esp-idf/v6.0.1/esp-idf-v6.0.1.zip",
"https://github.com/esphome-libs/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz",
# SSH and other git protocols are intentionally rejected — match
# external_components, which only recognizes github:// + structured
# dicts for these.
"git@github.com:espressif/esp-idf.git",
"ssh://git@github.com/espressif/esp-idf.git",
"git://github.com/espressif/esp-idf.git",
# non-GitHub .git URLs are intentionally rejected for the same reason
"https://gitlab.com/foo/bar.git",
"https://github.example.com/foo/bar.git",
],
)
def test_parse_git_source_rejected(source: str) -> None:
assert _parse_git_source(source) is None
def _make_idf_tree(framework_path: Path) -> None:
"""Create the minimum tree _clone_idf_with_submodules sanity-checks for."""
(framework_path / "tools").mkdir(parents=True)
(framework_path / "tools" / "idf_tools.py").write_text("# stub\n")
def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None:
framework_path = tmp_path / "idf"
framework_path.mkdir()
_make_idf_tree(framework_path)
with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock:
_clone_idf_with_submodules(
framework_path, "https://github.com/espressif/esp-idf.git", None
)
# No ref -> just clone + submodule update, no fetch/reset.
calls = [c.args[0] for c in run_git_command_mock.call_args_list]
assert calls[0] == [
"git",
"clone",
"--depth=1",
"--",
"https://github.com/espressif/esp-idf.git",
str(framework_path),
]
assert calls[-1][:5] == ["git", "submodule", "update", "--init", "--recursive"]
assert not any(c[1] == "fetch" for c in calls)
assert not any(c[1] == "reset" for c in calls)
def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None:
framework_path = tmp_path / "idf"
framework_path.mkdir()
_make_idf_tree(framework_path)
with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock:
_clone_idf_with_submodules(
framework_path,
"https://github.com/espressif/esp-idf.git",
"master",
)
calls = [c.args[0] for c in run_git_command_mock.call_args_list]
# clone, fetch ref, reset hard, submodule update
assert calls[0][:2] == ["git", "clone"]
assert calls[1] == [
"git",
"fetch",
"--depth=1",
"--",
"origin",
"master",
]
assert calls[2] == ["git", "reset", "--hard", "FETCH_HEAD"]
assert calls[3][:5] == ["git", "submodule", "update", "--init", "--recursive"]
def test_clone_idf_with_submodules_raises_when_tree_missing(
tmp_path: Path,
) -> None:
framework_path = tmp_path / "idf"
framework_path.mkdir()
# Deliberately do NOT call _make_idf_tree — simulate a clone that
# returned 0 but produced no tools/idf_tools.py.
with (
patch("esphome.git.run_git_command", return_value=""),
pytest.raises(RuntimeError, match="no usable ESP-IDF tree"),
):
_clone_idf_with_submodules(
framework_path,
"https://github.com/espressif/esp-idf.git",
None,
)