1# -*- coding: utf-8 -*-
2"""The Apple Framework builds require their own customization"""
3import logging
4import os
5import struct
6import subprocess
7from abc import ABCMeta, abstractmethod
8from textwrap import dedent
9
10from six import add_metaclass
11
12from virtualenv.create.via_global_ref.builtin.ref import ExePathRefToDest, PathRefToDest
13from virtualenv.util.path import Path
14from virtualenv.util.six import ensure_text
15
16from .common import CPython, CPythonPosix, is_mac_os_framework
17from .cpython2 import CPython2PosixBase
18from .cpython3 import CPython3
19
20
21@add_metaclass(ABCMeta)
22class CPythonmacOsFramework(CPython):
23    @classmethod
24    def can_describe(cls, interpreter):
25        return is_mac_os_framework(interpreter) and super(CPythonmacOsFramework, cls).can_describe(interpreter)
26
27    @classmethod
28    def sources(cls, interpreter):
29        for src in super(CPythonmacOsFramework, cls).sources(interpreter):
30            yield src
31        # add a symlink to the host python image
32        ref = PathRefToDest(cls.image_ref(interpreter), dest=lambda self, _: self.dest / ".Python", must_symlink=True)
33        yield ref
34
35    def create(self):
36        super(CPythonmacOsFramework, self).create()
37
38        # change the install_name of the copied python executables
39        target = "@executable_path/../.Python"
40        current = self.current_mach_o_image_path()
41        for src in self._sources:
42            if isinstance(src, ExePathRefToDest):
43                if src.must_copy or not self.symlinks:
44                    exes = [self.bin_dir / src.base]
45                    if not self.symlinks:
46                        exes.extend(self.bin_dir / a for a in src.aliases)
47                    for exe in exes:
48                        fix_mach_o(str(exe), current, target, self.interpreter.max_size)
49
50    @classmethod
51    def _executables(cls, interpreter):
52        for _, targets in super(CPythonmacOsFramework, cls)._executables(interpreter):
53            # Make sure we use the embedded interpreter inside the framework, even if sys.executable points to the
54            # stub executable in ${sys.prefix}/bin.
55            # See http://groups.google.com/group/python-virtualenv/browse_thread/thread/17cab2f85da75951
56            fixed_host_exe = Path(interpreter.prefix) / "Resources" / "Python.app" / "Contents" / "MacOS" / "Python"
57            yield fixed_host_exe, targets
58
59    @abstractmethod
60    def current_mach_o_image_path(self):
61        raise NotImplementedError
62
63    @classmethod
64    def image_ref(cls, interpreter):
65        raise NotImplementedError
66
67
68class CPython2macOsFramework(CPythonmacOsFramework, CPython2PosixBase):
69    @classmethod
70    def image_ref(cls, interpreter):
71        return Path(interpreter.prefix) / "Python"
72
73    def current_mach_o_image_path(self):
74        return os.path.join(self.interpreter.prefix, "Python")
75
76    @classmethod
77    def sources(cls, interpreter):
78        for src in super(CPython2macOsFramework, cls).sources(interpreter):
79            yield src
80        # landmark for exec_prefix
81        exec_marker_file, to_path, _ = cls.from_stdlib(cls.mappings(interpreter), "lib-dynload")
82        yield PathRefToDest(exec_marker_file, dest=to_path)
83
84    @property
85    def reload_code(self):
86        result = super(CPython2macOsFramework, self).reload_code
87        result = dedent(
88            """
89        # the bundled site.py always adds the global site package if we're on python framework build, escape this
90        import sysconfig
91        config = sysconfig.get_config_vars()
92        before = config["PYTHONFRAMEWORK"]
93        try:
94            config["PYTHONFRAMEWORK"] = ""
95            {}
96        finally:
97            config["PYTHONFRAMEWORK"] = before
98        """.format(
99                result,
100            ),
101        )
102        return result
103
104
105class CPython3macOsFramework(CPythonmacOsFramework, CPython3, CPythonPosix):
106    @classmethod
107    def image_ref(cls, interpreter):
108        return Path(interpreter.prefix) / "Python3"
109
110    def current_mach_o_image_path(self):
111        return "@executable_path/../../../../Python3"
112
113    @property
114    def reload_code(self):
115        result = super(CPython3macOsFramework, self).reload_code
116        result = dedent(
117            """
118        # the bundled site.py always adds the global site package if we're on python framework build, escape this
119        import sys
120        before = sys._framework
121        try:
122            sys._framework = None
123            {}
124        finally:
125            sys._framework = before
126        """.format(
127                result,
128            ),
129        )
130        return result
131
132
133def fix_mach_o(exe, current, new, max_size):
134    """
135    https://en.wikipedia.org/wiki/Mach-O
136
137    Mach-O, short for Mach object file format, is a file format for executables, object code, shared libraries,
138    dynamically-loaded code, and core dumps. A replacement for the a.out format, Mach-O offers more extensibility and
139    faster access to information in the symbol table.
140
141    Each Mach-O file is made up of one Mach-O header, followed by a series of load commands, followed by one or more
142    segments, each of which contains between 0 and 255 sections. Mach-O uses the REL relocation format to handle
143    references to symbols. When looking up symbols Mach-O uses a two-level namespace that encodes each symbol into an
144    'object/symbol name' pair that is then linearly searched for by first the object and then the symbol name.
145
146    The basic structure—a list of variable-length "load commands" that reference pages of data elsewhere in the file—was
147    also used in the executable file format for Accent. The Accent file format was in turn, based on an idea from Spice
148    Lisp.
149
150    With the introduction of Mac OS X 10.6 platform the Mach-O file underwent a significant modification that causes
151    binaries compiled on a computer running 10.6 or later to be (by default) executable only on computers running Mac
152    OS X 10.6 or later. The difference stems from load commands that the dynamic linker, in previous Mac OS X versions,
153    does not understand. Another significant change to the Mach-O format is the change in how the Link Edit tables
154    (found in the __LINKEDIT section) function. In 10.6 these new Link Edit tables are compressed by removing unused and
155    unneeded bits of information, however Mac OS X 10.5 and earlier cannot read this new Link Edit table format.
156    """
157    try:
158        logging.debug(u"change Mach-O for %s from %s to %s", ensure_text(exe), current, ensure_text(new))
159        _builtin_change_mach_o(max_size)(exe, current, new)
160    except Exception as e:
161        logging.warning("Could not call _builtin_change_mac_o: %s. " "Trying to call install_name_tool instead.", e)
162        try:
163            cmd = ["install_name_tool", "-change", current, new, exe]
164            subprocess.check_call(cmd)
165        except Exception:
166            logging.fatal("Could not call install_name_tool -- you must " "have Apple's development tools installed")
167            raise
168
169
170def _builtin_change_mach_o(maxint):
171    MH_MAGIC = 0xFEEDFACE
172    MH_CIGAM = 0xCEFAEDFE
173    MH_MAGIC_64 = 0xFEEDFACF
174    MH_CIGAM_64 = 0xCFFAEDFE
175    FAT_MAGIC = 0xCAFEBABE
176    BIG_ENDIAN = ">"
177    LITTLE_ENDIAN = "<"
178    LC_LOAD_DYLIB = 0xC
179
180    class FileView(object):
181        """A proxy for file-like objects that exposes a given view of a file. Modified from macholib."""
182
183        def __init__(self, file_obj, start=0, size=maxint):
184            if isinstance(file_obj, FileView):
185                self._file_obj = file_obj._file_obj
186            else:
187                self._file_obj = file_obj
188            self._start = start
189            self._end = start + size
190            self._pos = 0
191
192        def __repr__(self):
193            return "<fileview [{:d}, {:d}] {!r}>".format(self._start, self._end, self._file_obj)
194
195        def tell(self):
196            return self._pos
197
198        def _checkwindow(self, seek_to, op):
199            if not (self._start <= seek_to <= self._end):
200                msg = "{} to offset {:d} is outside window [{:d}, {:d}]".format(op, seek_to, self._start, self._end)
201                raise IOError(msg)
202
203        def seek(self, offset, whence=0):
204            seek_to = offset
205            if whence == os.SEEK_SET:
206                seek_to += self._start
207            elif whence == os.SEEK_CUR:
208                seek_to += self._start + self._pos
209            elif whence == os.SEEK_END:
210                seek_to += self._end
211            else:
212                raise IOError("Invalid whence argument to seek: {!r}".format(whence))
213            self._checkwindow(seek_to, "seek")
214            self._file_obj.seek(seek_to)
215            self._pos = seek_to - self._start
216
217        def write(self, content):
218            here = self._start + self._pos
219            self._checkwindow(here, "write")
220            self._checkwindow(here + len(content), "write")
221            self._file_obj.seek(here, os.SEEK_SET)
222            self._file_obj.write(content)
223            self._pos += len(content)
224
225        def read(self, size=maxint):
226            assert size >= 0
227            here = self._start + self._pos
228            self._checkwindow(here, "read")
229            size = min(size, self._end - here)
230            self._file_obj.seek(here, os.SEEK_SET)
231            read_bytes = self._file_obj.read(size)
232            self._pos += len(read_bytes)
233            return read_bytes
234
235    def read_data(file, endian, num=1):
236        """Read a given number of 32-bits unsigned integers from the given file with the given endianness."""
237        res = struct.unpack(endian + "L" * num, file.read(num * 4))
238        if len(res) == 1:
239            return res[0]
240        return res
241
242    def mach_o_change(at_path, what, value):
243        """Replace a given name (what) in any LC_LOAD_DYLIB command found in the given binary with a new name (value),
244         provided it's shorter."""
245
246        def do_macho(file, bits, endian):
247            # Read Mach-O header (the magic number is assumed read by the caller)
248            cpu_type, cpu_sub_type, file_type, n_commands, size_of_commands, flags = read_data(file, endian, 6)
249            # 64-bits header has one more field.
250            if bits == 64:
251                read_data(file, endian)
252            # The header is followed by n commands
253            for _ in range(n_commands):
254                where = file.tell()
255                # Read command header
256                cmd, cmd_size = read_data(file, endian, 2)
257                if cmd == LC_LOAD_DYLIB:
258                    # The first data field in LC_LOAD_DYLIB commands is the offset of the name, starting from the
259                    # beginning of the  command.
260                    name_offset = read_data(file, endian)
261                    file.seek(where + name_offset, os.SEEK_SET)
262                    # Read the NUL terminated string
263                    load = file.read(cmd_size - name_offset).decode()
264                    load = load[: load.index("\0")]
265                    # If the string is what is being replaced, overwrite it.
266                    if load == what:
267                        file.seek(where + name_offset, os.SEEK_SET)
268                        file.write(value.encode() + b"\0")
269                # Seek to the next command
270                file.seek(where + cmd_size, os.SEEK_SET)
271
272        def do_file(file, offset=0, size=maxint):
273            file = FileView(file, offset, size)
274            # Read magic number
275            magic = read_data(file, BIG_ENDIAN)
276            if magic == FAT_MAGIC:
277                # Fat binaries contain nfat_arch Mach-O binaries
278                n_fat_arch = read_data(file, BIG_ENDIAN)
279                for _ in range(n_fat_arch):
280                    # Read arch header
281                    cpu_type, cpu_sub_type, offset, size, align = read_data(file, BIG_ENDIAN, 5)
282                    do_file(file, offset, size)
283            elif magic == MH_MAGIC:
284                do_macho(file, 32, BIG_ENDIAN)
285            elif magic == MH_CIGAM:
286                do_macho(file, 32, LITTLE_ENDIAN)
287            elif magic == MH_MAGIC_64:
288                do_macho(file, 64, BIG_ENDIAN)
289            elif magic == MH_CIGAM_64:
290                do_macho(file, 64, LITTLE_ENDIAN)
291
292        assert len(what) >= len(value)
293
294        with open(at_path, "r+b") as f:
295            do_file(f)
296
297    return mach_o_change
298