"debputy: information from a dpkg-architecture subprocess"

import collections.abc
import os
import subprocess

_VARIABLES = (
    "ARCH",
    "ARCH_ABI",
    "ARCH_LIBC",
    "ARCH_OS",
    "ARCH_CPU",
    "ARCH_BITS",
    "ARCH_ENDIAN",
    "GNU_CPU",
    "GNU_SYSTEM",
    "GNU_TYPE",
    "MULTIARCH",
)


def _initial_cache() -> collections.abc.Iterator[tuple[str, str]]:
    for machine in ("BUILD", "HOST", "TARGET"):
        for variable in _VARIABLES:
            key = f"DEB_{machine}_{variable}"
            yield key, os.environ.get(key, "")


_cache = dict(_initial_cache())
# __getitem__() sets all empty values at once.

_fake = dict[str, dict[str, str]]()
# The constructor extends _fake if necessary for new mocked instances.
# _fake["amd64"]["ARCH_ABI"] == "base"


class DpkgArchitectureBuildProcessValuesTable(
    collections.abc.Mapping[str, str],
    # An implementation requires __getitem__ __len__ __iter__.
    # Overriding __str__ seem unneeded, and even harmful when debugging.
):
    """Dict-like interface to dpkg-architecture values"""

    def __init__(self, *, fake_host="", fake_build="", fake_target="") -> None:
        """Create a new dpkg-architecture table.

        The keys are the dpkg-architecture variables like
        DEB_HOST_ARCH, DEB_BUILD_GNU_TYPE.

        The caching mechanism assumes that variables affecting
        dpkg-architecture are constant in os.environ.

        The optional parameters are intended for testing purposes.

        :param fake_host: if set, the instance is a mocked instance
        for testing purposes. This affects the DEB_HOST_* variables.

        :param fake_build: (ignored without fake_host, defaults to the
        same value) if distinct from fake_host, then pretend this is a
        cross-build. This affects the DEB_BUILD_* variables.

        :param fake_target: (ignored without fake_host, defaults to
        the same value): if distinct from fake_host, then pretend this
        is a build _of_ a cross-compiler. This affects the
        DEB_TARGET_* variables.

        """
        self._mock: dict[str, str] | None
        if fake_host:
            self._mock = {
                "HOST": fake_host,
                "BUILD": fake_build or fake_host,
                "TARGET": fake_target or fake_host,
            }
            _ensure_in_fake(self._mock.values())
        else:
            self._mock = None

    def __getitem__(self, item: str) -> str:
        if self._mock:
            match item.split(sep="_", maxsplit=2):
                case "DEB", machine, variable:
                    # Raise KeyError on unexpected machine or variable.
                    return _fake[self._mock[machine]][variable]
            raise KeyError
        # This is a normal instance.
        # Raise KeyError on unexpected keys.
        value = _cache[item]
        if value:
            return value
        # dpkg-architecture has not been run yet.
        # The variable was missing or empty in the environment.
        for k, v in _parse_dpkg_arch_output((), {}):
            old = _cache[k]
            if old:
                assert old == v, f"{k}={old} in env, {v} for dpkg-architecture"
            else:
                _cache[k] = v
        return _cache[item]

    def __len__(self) -> int:
        return len(_cache)

    def __iter__(self) -> collections.abc.Iterator[str]:
        return iter(_cache)

    @property
    def current_host_arch(self) -> str:
        """The architecture we are building for

        This is the architecture name you need if you are in doubt.
        """
        return self["DEB_HOST_ARCH"]

    @property
    def current_host_multiarch(self) -> str:
        """The multi-arch path basename

        This is the multi-arch basename name you need if you are in doubt.  It
        goes here:

            "/usr/lib/{MA}".format(table.current_host_multiarch)

        """
        return self["DEB_HOST_MULTIARCH"]

    @property
    def is_cross_compiling(self) -> bool:
        """Whether we are cross-compiling

        This is defined as DEB_BUILD_GNU_TYPE != DEB_HOST_GNU_TYPE and
        affects whether we can rely on being able to run the binaries
        that are compiled.
        """
        return self["DEB_BUILD_GNU_TYPE"] != self["DEB_HOST_GNU_TYPE"]


def _parse_dpkg_arch_output(
    args: collections.abc.Iterable[str],
    env: dict[str, str],
) -> collections.abc.Iterator[tuple[str, str]]:
    # For performance, disable dpkg's translation later
    text = subprocess.check_output(
        args=("dpkg-architecture", *args),
        env=collections.ChainMap(env, {"DPKG_NLS": "0"}, os.environ),
        encoding="utf-8",
    )
    for line in text.splitlines():
        k, v = line.strip().split("=", 1)
        yield k, v


def _ensure_in_fake(archs: collections.abc.Iterable[str]) -> None:
    # len(archs) == 3
    # Remove duplicates and already cached architectures.
    todo = {a for a in archs if a not in _fake}
    if not todo:
        return

    env = {}
    # Set CC to /bin/true avoid a warning from dpkg-architecture
    env["CC"] = "/bin/true"
    # Clear environ variables that might confuse dpkg-architecture
    for k in os.environ:
        if k.startswith("DEB_"):
            env[k] = ""

    while todo:
        # Each iteration consumes at least 1 element.
        args = ["--host-arch", todo.pop()]
        if todo:
            args.extend(("--target-arch", todo.pop()))
        kw = dict(_parse_dpkg_arch_output(args, env))

        h = kw["DEB_HOST_ARCH"]
        assert h not in _fake and h not in todo
        _fake[h] = dict((v, kw[f"DEB_HOST_{v}"]) for v in _VARIABLES)

        t = kw["DEB_TARGET_ARCH"]
        assert t not in todo
        if t not in _fake:
            _fake[t] = dict((v, kw[f"DEB_TARGET_{v}"]) for v in _VARIABLES)

        b = kw["DEB_BUILD_ARCH"]
        if b not in _fake:
            _fake[b] = dict((v, (kw[f"DEB_BUILD_{v}"])) for v in _VARIABLES)
            if b in todo:
                todo.remove(b)
