"""Generate and validate the dockerfile.""" from dataclasses import dataclass from pathlib import Path from homeassistant import core from homeassistant.util import executor, thread from .model import Config, Integration from .requirements import PACKAGE_REGEX, PIP_VERSION_RANGE_SEPARATOR _GO2RTC_SHA = ( "675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae" # 1.9.14 ) DOCKERFILE_TEMPLATE = r"""# Automatically generated by hassfest. # # To update, run python3 -m script.hassfest -p docker ARG BUILD_FROM FROM ${{BUILD_FROM}} LABEL \ io.hass.type="core" \ org.opencontainers.image.authors="The Home Assistant Authors" \ org.opencontainers.image.description="Open-source home automation platform running on Python 3" \ org.opencontainers.image.documentation="https://www.home-assistant.io/docs/" \ org.opencontainers.image.licenses="Apache-2.0" \ org.opencontainers.image.source="https://github.com/home-assistant/core" \ org.opencontainers.image.title="Home Assistant" \ org.opencontainers.image.url="https://www.home-assistant.io/" # Synchronize with homeassistant/core.py:async_stop ENV \ S6_SERVICES_GRACETIME={timeout} \ UV_SYSTEM_PYTHON=true \ UV_NO_CACHE=true # Home Assistant S6-Overlay COPY rootfs / # Add go2rtc binary COPY --from=ghcr.io/alexxit/go2rtc@sha256:{go2rtc} /usr/local/bin/go2rtc /bin/go2rtc RUN \ # Verify go2rtc can be executed go2rtc --version \ # Install uv && pip3 install uv=={uv} WORKDIR /usr/src ## Setup Home Assistant Core dependencies COPY requirements.txt homeassistant/ COPY homeassistant/package_constraints.txt homeassistant/homeassistant/ RUN \ uv pip install \ --no-build \ -r homeassistant/requirements.txt COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ RUN \ if ls homeassistant/home_assistant_*.whl 1> /dev/null 2>&1; then \ uv pip install homeassistant/home_assistant_*.whl; \ fi \ && uv pip install \ --no-build \ -r homeassistant/requirements_all.txt ## Setup Home Assistant Core COPY . homeassistant/ RUN \ uv pip install \ -e ./homeassistant \ && python3 -m compileall \ homeassistant/homeassistant WORKDIR /config """ _HASSFEST_TEMPLATE = r"""# Automatically generated by hassfest. # # To update, run python3 -m script.hassfest -p docker FROM python:3.14-alpine ENV \ UV_SYSTEM_PYTHON=true \ UV_EXTRA_INDEX_URL="https://wheels.home-assistant.io/musllinux-index/" SHELL ["/bin/sh", "-o", "pipefail", "-c"] ENTRYPOINT ["/usr/src/homeassistant/script/hassfest/docker/entrypoint.sh"] WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build RUN --mount=from=ghcr.io/astral-sh/uv:{uv},source=/uv,target=/bin/uv \ # Uv creates a lock file in /tmp --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ && uv pip install \ --no-build \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ pipdeptree=={pipdeptree} \ tqdm=={tqdm} \ ruff=={ruff} LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " LABEL "com.github.actions.name"="hassfest" LABEL "com.github.actions.description"="Run hassfest to validate standalone integration repositories" LABEL "com.github.actions.icon"="terminal" LABEL "com.github.actions.color"="gray-dark" """ def _get_package_versions(file: Path, packages: set[str]) -> dict[str, str]: package_versions: dict[str, str] = {} with file.open(encoding="UTF-8") as fp: for _, line in enumerate(fp): if package_versions.keys() == packages: return package_versions if match := PACKAGE_REGEX.match(line): pkg, sep, version = match.groups() if pkg not in packages: continue if sep != "==" or not version: raise RuntimeError( f'Requirement {pkg} need to be pinned "{pkg}==".' ) for part in version.split(";", 1)[0].split(","): version_part = PIP_VERSION_RANGE_SEPARATOR.match(part) if version_part: package_versions[pkg] = version_part.group(2) break if package_versions.keys() == packages: return package_versions raise RuntimeError("At least one package was not found in the requirements file.") @dataclass class File: """File.""" content: str path: Path def _generate_files(config: Config) -> list[File]: timeout = ( core.STOPPING_STAGE_SHUTDOWN_TIMEOUT + core.STOP_STAGE_SHUTDOWN_TIMEOUT + core.FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT + core.CLOSE_STAGE_SHUTDOWN_TIMEOUT + executor.EXECUTOR_SHUTDOWN_TIMEOUT + thread.THREADING_SHUTDOWN_TIMEOUT + 10 ) * 1000 package_versions = _get_package_versions(config.root / "requirements.txt", {"uv"}) package_versions |= _get_package_versions( config.root / "requirements_test.txt", {"pipdeptree", "tqdm"} ) package_versions |= _get_package_versions( config.root / "requirements_test_pre_commit.txt", {"ruff"} ) return [ File( DOCKERFILE_TEMPLATE.format( timeout=timeout, **package_versions, go2rtc=_GO2RTC_SHA, ), config.root / "Dockerfile", ), File( _HASSFEST_TEMPLATE.format( timeout=timeout, **package_versions, ), config.root / "script/hassfest/docker/Dockerfile", ), ] def validate(integrations: dict[str, Integration], config: Config) -> None: """Validate dockerfile.""" docker_files = _generate_files(config) config.cache["docker"] = docker_files for file in docker_files: if file.content != file.path.read_text(): config.add_error( "docker", f"File {file.path} is not up to date. Run python3 -m script.hassfest", fixable=True, ) def generate(integrations: dict[str, Integration], config: Config) -> None: """Generate dockerfile.""" for file in _generate_files(config): file.path.write_text(file.content)