1"""PEP 656 support.
2
3This module implements logic to detect if the currently running Python is
4linked against musl, and what musl version is used.
5"""
6
7import contextlib
8import functools
9import operator
10import os
11import re
12import struct
13import subprocess
14import sys
15from typing import IO, Iterator, NamedTuple, Optional, Tuple
16
17
18def _read_unpacked(f: IO[bytes], fmt: str) -> Tuple[int, ...]:
19    return struct.unpack(fmt, f.read(struct.calcsize(fmt)))
20
21
22def _parse_ld_musl_from_elf(f: IO[bytes]) -> Optional[str]:
23    """Detect musl libc location by parsing the Python executable.
24
25    Based on: https://gist.github.com/lyssdod/f51579ae8d93c8657a5564aefc2ffbca
26    ELF header: https://refspecs.linuxfoundation.org/elf/gabi4+/ch4.eheader.html
27    """
28    f.seek(0)
29    try:
30        ident = _read_unpacked(f, "16B")
31    except struct.error:
32        return None
33    if ident[:4] != tuple(b"\x7fELF"):  # Invalid magic, not ELF.
34        return None
35    f.seek(struct.calcsize("HHI"), 1)  # Skip file type, machine, and version.
36
37    try:
38        # e_fmt: Format for program header.
39        # p_fmt: Format for section header.
40        # p_idx: Indexes to find p_type, p_offset, and p_filesz.
41        e_fmt, p_fmt, p_idx = {
42            1: ("IIIIHHH", "IIIIIIII", (0, 1, 4)),  # 32-bit.
43            2: ("QQQIHHH", "IIQQQQQQ", (0, 2, 5)),  # 64-bit.
44        }[ident[4]]
45    except KeyError:
46        return None
47    else:
48        p_get = operator.itemgetter(*p_idx)
49
50    # Find the interpreter section and return its content.
51    try:
52        _, e_phoff, _, _, _, e_phentsize, e_phnum = _read_unpacked(f, e_fmt)
53    except struct.error:
54        return None
55    for i in range(e_phnum + 1):
56        f.seek(e_phoff + e_phentsize * i)
57        try:
58            p_type, p_offset, p_filesz = p_get(_read_unpacked(f, p_fmt))
59        except struct.error:
60            return None
61        if p_type != 3:  # Not PT_INTERP.
62            continue
63        f.seek(p_offset)
64        interpreter = os.fsdecode(f.read(p_filesz)).strip("\0")
65        if "musl" not in interpreter:
66            return None
67        return interpreter
68    return None
69
70
71class _MuslVersion(NamedTuple):
72    major: int
73    minor: int
74
75
76def _parse_musl_version(output: str) -> Optional[_MuslVersion]:
77    lines = [n for n in (n.strip() for n in output.splitlines()) if n]
78    if len(lines) < 2 or lines[0][:4] != "musl":
79        return None
80    m = re.match(r"Version (\d+)\.(\d+)", lines[1])
81    if not m:
82        return None
83    return _MuslVersion(major=int(m.group(1)), minor=int(m.group(2)))
84
85
86@functools.lru_cache()
87def _get_musl_version(executable: str) -> Optional[_MuslVersion]:
88    """Detect currently-running musl runtime version.
89
90    This is done by checking the specified executable's dynamic linking
91    information, and invoking the loader to parse its output for a version
92    string. If the loader is musl, the output would be something like::
93
94        musl libc (x86_64)
95        Version 1.2.2
96        Dynamic Program Loader
97    """
98    with contextlib.ExitStack() as stack:
99        try:
100            f = stack.enter_context(open(executable, "rb"))
101        except OSError:
102            return None
103        ld = _parse_ld_musl_from_elf(f)
104    if not ld:
105        return None
106    proc = subprocess.run([ld], stderr=subprocess.PIPE, universal_newlines=True)
107    return _parse_musl_version(proc.stderr)
108
109
110def platform_tags(arch: str) -> Iterator[str]:
111    """Generate musllinux tags compatible to the current platform.
112
113    :param arch: Should be the part of platform tag after the ``linux_``
114        prefix, e.g. ``x86_64``. The ``linux_`` prefix is assumed as a
115        prerequisite for the current platform to be musllinux-compatible.
116
117    :returns: An iterator of compatible musllinux tags.
118    """
119    sys_musl = _get_musl_version(sys.executable)
120    if sys_musl is None:  # Python not dynamically linked against musl.
121        return
122    for minor in range(sys_musl.minor, -1, -1):
123        yield f"musllinux_{sys_musl.major}_{minor}_{arch}"
124
125
126if __name__ == "__main__":  # pragma: no cover
127    import sysconfig
128
129    plat = sysconfig.get_platform()
130    assert plat.startswith("linux-"), "not linux"
131
132    print("plat:", plat)
133    print("musl:", _get_musl_version(sys.executable))
134    print("tags:", end=" ")
135    for t in platform_tags(re.sub(r"[.-]", "_", plat.split("-", 1)[-1])):
136        print(t, end="\n      ")
137