1# This Source Code Form is subject to the terms of the Mozilla Public
2# License, v. 2.0. If a copy of the MPL was not distributed with this
3# file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5# This file contains code for populating the virtualenv environment for
6# Mozilla's build system. It is typically called as part of configure.
7
8from __future__ import absolute_import, print_function, unicode_literals
9
10import argparse
11import json
12import os
13import platform
14import shutil
15import subprocess
16import sys
17
18IS_NATIVE_WIN = sys.platform == "win32" and os.sep == "\\"
19IS_CYGWIN = sys.platform == "cygwin"
20
21
22UPGRADE_WINDOWS = """
23Please upgrade to the latest MozillaBuild development environment. See
24https://developer.mozilla.org/en-US/docs/Developer_Guide/Build_Instructions/Windows_Prerequisites
25""".lstrip()
26
27UPGRADE_OTHER = """
28Run |mach bootstrap| to ensure your system is up to date.
29
30If you still receive this error, your shell environment is likely detecting
31another Python version. Ensure a modern Python can be found in the paths
32defined by the $PATH environment variable and try again.
33""".lstrip()
34
35here = os.path.abspath(os.path.dirname(__file__))
36
37
38# We can't import six.ensure_binary() or six.ensure_text() because this module
39# has to run stand-alone.  Instead we'll implement an abbreviated version of the
40# checks it does.
41
42
43class VirtualenvHelper(object):
44    """Contains basic logic for getting information about virtualenvs."""
45
46    def __init__(self, virtualenv_path):
47        self.virtualenv_root = virtualenv_path
48
49    @property
50    def bin_path(self):
51        # virtualenv.py provides a similar API via path_locations(). However,
52        # we have a bit of a chicken-and-egg problem and can't reliably
53        # import virtualenv. The functionality is trivial, so just implement
54        # it here.
55        if IS_CYGWIN or IS_NATIVE_WIN:
56            return os.path.join(self.virtualenv_root, "Scripts")
57
58        return os.path.join(self.virtualenv_root, "bin")
59
60    @property
61    def python_path(self):
62        binary = "python"
63        if sys.platform in ("win32", "cygwin"):
64            binary += ".exe"
65
66        return os.path.join(self.bin_path, binary)
67
68
69class VirtualenvManager(VirtualenvHelper):
70    """Contains logic for managing virtualenvs for building the tree."""
71
72    def __init__(
73        self,
74        topsrcdir,
75        virtualenv_path,
76        log_handle,
77        manifest_path,
78        populate_local_paths=True,
79    ):
80        """Create a new manager.
81
82        Each manager is associated with a source directory, a path where you
83        want the virtualenv to be created, and a handle to write output to.
84        """
85        super(VirtualenvManager, self).__init__(virtualenv_path)
86
87        # __PYVENV_LAUNCHER__ confuses pip, telling it to use the system
88        # python interpreter rather than the local virtual environment interpreter.
89        # See https://bugzilla.mozilla.org/show_bug.cgi?id=1607470
90        os.environ.pop("__PYVENV_LAUNCHER__", None)
91
92        assert os.path.isabs(
93            manifest_path
94        ), "manifest_path must be an absolute path: %s" % (manifest_path)
95        self.topsrcdir = topsrcdir
96
97        # Record the Python executable that was used to create the Virtualenv
98        # so we can check this against sys.executable when verifying the
99        # integrity of the virtualenv.
100        self.exe_info_path = os.path.join(self.virtualenv_root, "python_exe.txt")
101
102        self.log_handle = log_handle
103        self.manifest_path = manifest_path
104        self.populate_local_paths = populate_local_paths
105
106    @property
107    def virtualenv_script_path(self):
108        """Path to virtualenv's own populator script."""
109        return os.path.join(
110            self.topsrcdir, "third_party", "python", "virtualenv", "virtualenv.py"
111        )
112
113    def version_info(self):
114        return eval(
115            subprocess.check_output(
116                [self.python_path, "-c", "import sys; print(sys.version_info[:])"]
117            )
118        )
119
120    @property
121    def activate_path(self):
122        return os.path.join(self.bin_path, "activate_this.py")
123
124    def get_exe_info(self):
125        """Returns the version of the python executable that was in use when
126        this virtualenv was created.
127        """
128        with open(self.exe_info_path, "r") as fh:
129            version = fh.read()
130        return int(version)
131
132    def write_exe_info(self, python):
133        """Records the the version of the python executable that was in use when
134        this virtualenv was created. We record this explicitly because
135        on OS X our python path may end up being a different or modified
136        executable.
137        """
138        ver = self.python_executable_hexversion(python)
139        with open(self.exe_info_path, "w") as fh:
140            fh.write("%s\n" % ver)
141
142    def python_executable_hexversion(self, python):
143        """Run a Python executable and return its sys.hexversion value."""
144        program = "import sys; print(sys.hexversion)"
145        out = subprocess.check_output([python, "-c", program]).rstrip()
146        return int(out)
147
148    def up_to_date(self, python):
149        """Returns whether the virtualenv is present and up to date.
150
151        Args:
152            python: Full path string to the Python executable that this virtualenv
153                should be running.  If the Python executable passed in to this
154                argument is not the same version as the Python the virtualenv was
155                built with then this method will return False.
156        """
157
158        deps = [self.manifest_path, __file__]
159
160        # check if virtualenv exists
161        if not os.path.exists(self.virtualenv_root) or not os.path.exists(
162            self.activate_path
163        ):
164            return False
165
166        # Modifications to our package dependency list or to this file mean the
167        # virtualenv should be rebuilt.
168        activate_mtime = os.path.getmtime(self.activate_path)
169        dep_mtime = max(os.path.getmtime(p) for p in deps)
170        if dep_mtime > activate_mtime:
171            return False
172
173        # Verify that the Python we're checking here is either the virutalenv
174        # python, or we have the Python version that was used to create the
175        # virtualenv. If this fails, it is likely system Python has been
176        # upgraded, and our virtualenv would not be usable.
177        orig_version = self.get_exe_info()
178        hexversion = self.python_executable_hexversion(python)
179        if (python != self.python_path) and (hexversion != orig_version):
180            return False
181
182        packages = self.packages()
183        pypi_packages = [package for action, package in packages if action == "pypi"]
184        if pypi_packages:
185            pip_json = self._run_pip(
186                ["list", "--format", "json"], capture_output=True
187            ).stdout
188            installed_packages = json.loads(pip_json)
189            installed_packages = {
190                package["name"]: package["version"] for package in installed_packages
191            }
192            for pypi_package in pypi_packages:
193                name, version = pypi_package.split("==")
194                if installed_packages.get(name, None) != version:
195                    return False
196
197        # recursively check sub packages.txt files
198        submanifests = [
199            package for action, package in packages if action == "packages.txt"
200        ]
201        for submanifest in submanifests:
202            submanifest = os.path.join(self.topsrcdir, submanifest)
203            submanager = VirtualenvManager(
204                self.topsrcdir, self.virtualenv_root, self.log_handle, submanifest
205            )
206            if not submanager.up_to_date(python):
207                return False
208
209        return True
210
211    def ensure(self, python=sys.executable):
212        """Ensure the virtualenv is present and up to date.
213
214        If the virtualenv is up to date, this does nothing. Otherwise, it
215        creates and populates the virtualenv as necessary.
216
217        This should be the main API used from this class as it is the
218        highest-level.
219        """
220        if self.up_to_date(python):
221            return self.virtualenv_root
222        return self.build(python)
223
224    def _log_process_output(self, *args, **kwargs):
225        if hasattr(self.log_handle, "fileno"):
226            return subprocess.call(
227                *args, stdout=self.log_handle, stderr=subprocess.STDOUT, **kwargs
228            )
229
230        proc = subprocess.Popen(
231            *args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **kwargs
232        )
233
234        for line in proc.stdout:
235            self.log_handle.write(line.decode("UTF-8"))
236
237        return proc.wait()
238
239    def create(self, python):
240        """Create a new, empty virtualenv.
241
242        Receives the path to virtualenv's virtualenv.py script (which will be
243        called out to), the path to create the virtualenv in, and a handle to
244        write output to.
245        """
246        if os.path.exists(self.virtualenv_root):
247            shutil.rmtree(self.virtualenv_root)
248
249        args = [
250            python,
251            self.virtualenv_script_path,
252            # Without this, virtualenv.py may attempt to contact the outside
253            # world and search for or download a newer version of pip,
254            # setuptools, or wheel. This is bad for security, reproducibility,
255            # and speed.
256            "--no-download",
257            self.virtualenv_root,
258        ]
259
260        result = self._log_process_output(args)
261
262        if result:
263            raise Exception(
264                "Failed to create virtualenv: %s (virtualenv.py retcode: %s)"
265                % (self.virtualenv_root, result)
266            )
267
268        self.write_exe_info(python)
269        self._disable_pip_outdated_warning()
270
271        return self.virtualenv_root
272
273    def packages(self):
274        with open(self.manifest_path, "r") as fh:
275            return [line.rstrip().split(":", maxsplit=1) for line in fh]
276
277    def populate(self):
278        """Populate the virtualenv.
279
280        The manifest file consists of colon-delimited fields. The first field
281        specifies the action. The remaining fields are arguments to that
282        action. The following actions are supported:
283
284        pth -- Adds the path given as argument to "mach.pth" under
285            the virtualenv site packages directory.
286
287        pypi -- Fetch the package, plus dependencies, from PyPI.
288
289        thunderbird -- This denotes the action as to only occur for Thunderbird
290            checkouts. The initial "thunderbird" field is stripped, then the
291            remaining line is processed like normal. e.g.
292            "thunderbird:pth:python/foo"
293
294        packages.txt -- Denotes that the specified path is a child manifest. It
295            will be read and processed as if its contents were concatenated
296            into the manifest being read.
297
298        Note that the Python interpreter running this function should be the
299        one from the virtualenv. If it is the system Python or if the
300        environment is not configured properly, packages could be installed
301        into the wrong place. This is how virtualenv's work.
302        """
303        import distutils.sysconfig
304
305        thunderbird_dir = os.path.join(self.topsrcdir, "comm")
306        is_thunderbird = os.path.exists(thunderbird_dir) and bool(
307            os.listdir(thunderbird_dir)
308        )
309        python_lib = distutils.sysconfig.get_python_lib()
310
311        def handle_package(action, package):
312            if action == "packages.txt":
313                src = os.path.join(self.topsrcdir, package)
314                assert os.path.isfile(src), "'%s' does not exist" % src
315                submanager = VirtualenvManager(
316                    self.topsrcdir,
317                    self.virtualenv_root,
318                    self.log_handle,
319                    src,
320                    populate_local_paths=self.populate_local_paths,
321                )
322                submanager.populate()
323            elif action == "pth":
324                if not self.populate_local_paths:
325                    return
326
327                path = os.path.join(self.topsrcdir, package)
328
329                with open(os.path.join(python_lib, "mach.pth"), "a") as f:
330                    # This path is relative to the .pth file.  Using a
331                    # relative path allows the srcdir/objdir combination
332                    # to be moved around (as long as the paths relative to
333                    # each other remain the same).
334                    f.write("%s\n" % os.path.relpath(path, python_lib))
335            elif action == "thunderbird":
336                if is_thunderbird:
337                    handle_package(*package.split(":", maxsplit=1))
338            elif action == "pypi":
339                if len(package.split("==")) != 2:
340                    raise Exception(
341                        "Expected pypi package version to be pinned in the "
342                        'format "package==version", found "{}"'.format(package)
343                    )
344                self.install_pip_package(package)
345            else:
346                raise Exception("Unknown action: %s" % action)
347
348        # We always target the OS X deployment target that Python itself was
349        # built with, regardless of what's in the current environment. If we
350        # don't do # this, we may run into a Python bug. See
351        # http://bugs.python.org/issue9516 and bug 659881.
352        #
353        # Note that this assumes that nothing compiled in the virtualenv is
354        # shipped as part of a distribution. If we do ship anything, the
355        # deployment target here may be different from what's targeted by the
356        # shipping binaries and # virtualenv-produced binaries may fail to
357        # work.
358        #
359        # We also ignore environment variables that may have been altered by
360        # configure or a mozconfig activated in the current shell. We trust
361        # Python is smart enough to find a proper compiler and to use the
362        # proper compiler flags. If it isn't your Python is likely broken.
363        IGNORE_ENV_VARIABLES = ("CC", "CXX", "CFLAGS", "CXXFLAGS", "LDFLAGS")
364
365        try:
366            old_target = os.environ.get("MACOSX_DEPLOYMENT_TARGET", None)
367            sysconfig_target = distutils.sysconfig.get_config_var(
368                "MACOSX_DEPLOYMENT_TARGET"
369            )
370
371            if sysconfig_target is not None:
372                # MACOSX_DEPLOYMENT_TARGET is usually a string (e.g.: "10.14.6"), but
373                # in some cases it is an int (e.g.: 11). Since environment variables
374                # must all be str, explicitly convert it.
375                os.environ["MACOSX_DEPLOYMENT_TARGET"] = str(sysconfig_target)
376
377            old_env_variables = {}
378            for k in IGNORE_ENV_VARIABLES:
379                if k not in os.environ:
380                    continue
381
382                old_env_variables[k] = os.environ[k]
383                del os.environ[k]
384
385            for current_action, current_package in self.packages():
386                handle_package(current_action, current_package)
387
388        finally:
389            os.environ.pop("MACOSX_DEPLOYMENT_TARGET", None)
390
391            if old_target is not None:
392                os.environ["MACOSX_DEPLOYMENT_TARGET"] = old_target
393
394            os.environ.update(old_env_variables)
395
396    def call_setup(self, directory, arguments):
397        """Calls setup.py in a directory."""
398        setup = os.path.join(directory, "setup.py")
399
400        program = [self.python_path, setup]
401        program.extend(arguments)
402
403        # We probably could call the contents of this file inside the context
404        # of this interpreter using execfile() or similar. However, if global
405        # variables like sys.path are adjusted, this could cause all kinds of
406        # havoc. While this may work, invoking a new process is safer.
407
408        try:
409            env = os.environ.copy()
410            env.setdefault("ARCHFLAGS", get_archflags())
411            output = subprocess.check_output(
412                program,
413                cwd=directory,
414                env=env,
415                stderr=subprocess.STDOUT,
416                universal_newlines=True,
417            )
418            print(output)
419        except subprocess.CalledProcessError as e:
420            if "Python.h: No such file or directory" in e.output:
421                print(
422                    "WARNING: Python.h not found. Install Python development headers."
423                )
424            else:
425                print(e.output)
426
427            raise Exception("Error installing package: %s" % directory)
428
429    def build(self, python):
430        """Build a virtualenv per tree conventions.
431
432        This returns the path of the created virtualenv.
433        """
434        self.create(python)
435
436        # We need to populate the virtualenv using the Python executable in
437        # the virtualenv for paths to be proper.
438
439        # If this module was run from Python 2 then the __file__ attribute may
440        # point to a Python 2 .pyc file. If we are generating a Python 3
441        # virtualenv from Python 2 make sure we call Python 3 with the path to
442        # the module and not the Python 2 .pyc file.
443        if os.path.splitext(__file__)[1] in (".pyc", ".pyo"):
444            thismodule = __file__[:-1]
445        else:
446            thismodule = __file__
447
448        args = [
449            self.python_path,
450            thismodule,
451            "populate",
452            self.topsrcdir,
453            self.virtualenv_root,
454            self.manifest_path,
455        ]
456        if self.populate_local_paths:
457            args.append("--populate-local-paths")
458
459        result = self._log_process_output(args, cwd=self.topsrcdir)
460
461        if result != 0:
462            raise Exception("Error populating virtualenv.")
463
464        os.utime(self.activate_path, None)
465
466        return self.virtualenv_root
467
468    def activate(self):
469        """Activate the virtualenv in this Python context.
470
471        If you run a random Python script and wish to "activate" the
472        virtualenv, you can simply instantiate an instance of this class
473        and call .ensure() and .activate() to make the virtualenv active.
474        """
475
476        exec(open(self.activate_path).read(), dict(__file__=self.activate_path))
477
478    def install_pip_package(self, package, vendored=False):
479        """Install a package via pip.
480
481        The supplied package is specified using a pip requirement specifier.
482        e.g. 'foo' or 'foo==1.0'.
483
484        If the package is already installed, this is a no-op.
485
486        If vendored is True, no package index will be used and no dependencies
487        will be installed.
488        """
489        import mozfile
490        from mozfile import TemporaryDirectory
491
492        if sys.executable.startswith(self.bin_path):
493            # If we're already running in this interpreter, we can optimize in
494            # the case that the package requirement is already satisfied.
495            from pip._internal.req.constructors import install_req_from_line
496
497            req = install_req_from_line(package)
498            req.check_if_exists(use_user_site=False)
499            if req.satisfied_by is not None:
500                return
501
502        args = ["install"]
503        vendored_dist_info_dir = None
504
505        if vendored:
506            args.extend(
507                [
508                    "--no-deps",
509                    "--no-index",
510                    # The setup will by default be performed in an isolated build
511                    # environment, and since we're running with --no-index, this
512                    # means that pip will be unable to install in the isolated build
513                    # environment any dependencies that might be specified in a
514                    # setup_requires directive for the package. Since we're manually
515                    # controlling our build environment, build isolation isn't a
516                    # concern and we can disable that feature. Note that this is
517                    # safe and doesn't risk trampling any other packages that may be
518                    # installed due to passing `--no-deps --no-index` as well.
519                    "--no-build-isolation",
520                ]
521            )
522            vendored_dist_info_dir = next(
523                (d for d in os.listdir(package) if d.endswith(".dist-info")), None
524            )
525
526        with TemporaryDirectory() as tmp:
527            if vendored_dist_info_dir:
528                # This is a vendored wheel. We have to re-pack it in order for pip
529                # to install it.
530                wheel_file = os.path.join(
531                    tmp, "{}-1.0-py3-none-any.whl".format(os.path.basename(package))
532                )
533                shutil.make_archive(wheel_file, "zip", package)
534                mozfile.move("{}.zip".format(wheel_file), wheel_file)
535                package = wheel_file
536
537            args.append(package)
538            return self._run_pip(args, stderr=subprocess.STDOUT)
539
540    def install_pip_requirements(
541        self, path, require_hashes=True, quiet=False, vendored=False
542    ):
543        """Install a pip requirements.txt file.
544
545        The supplied path is a text file containing pip requirement
546        specifiers.
547
548        If require_hashes is True, each specifier must contain the
549        expected hash of the downloaded package. See:
550        https://pip.pypa.io/en/stable/reference/pip_install/#hash-checking-mode
551        """
552
553        if not os.path.isabs(path):
554            path = os.path.join(self.topsrcdir, path)
555
556        args = ["install", "--requirement", path]
557
558        if require_hashes:
559            args.append("--require-hashes")
560
561        if quiet:
562            args.append("--quiet")
563
564        if vendored:
565            args.extend(["--no-deps", "--no-index"])
566
567        return self._run_pip(args, stderr=subprocess.STDOUT)
568
569    def _disable_pip_outdated_warning(self):
570        """Disables the pip outdated warning by changing pip's 'installer'
571
572        "pip" has behaviour to ensure that it doesn't print it's "outdated"
573        warning if it's part of a Linux distro package. This is because
574        Linux distros generally have a slightly out-of-date pip package
575        that they know to be stable, and users aren't always able to
576        (or want to) update it.
577
578        This behaviour works by checking if the "pip" installer
579        (encoded in the dist-info/INSTALLER file) is "pip" itself,
580        or a different value (e.g.: a distro).
581
582        We can take advantage of this behaviour by telling pip
583        that it was installed by "mach", so it won't print the
584        warning.
585
586        https://github.com/pypa/pip/blob/5ee933aab81273da3691c97f2a6e7016ecbe0ef9/src/pip/_internal/self_outdated_check.py#L100-L101 # noqa F401
587        """
588
589        # Defer "distutils" import until this function is called so that
590        # "mach bootstrap" doesn't fail due to Linux distro python-distutils
591        # package not being installed.
592        # By the time this function is called, "distutils" must be installed
593        # because it's needed by the "virtualenv" package.
594        from distutils import dist
595
596        distribution = dist.Distribution({"script_args": "--no-user-cfg"})
597        installer = distribution.get_command_obj("install")
598        installer.prefix = os.path.normpath(self.virtualenv_root)
599        installer.finalize_options()
600
601        # Path to virtualenv's "site-packages" directory
602        site_packages = installer.install_purelib
603
604        pip_dist_info = next(
605            (
606                file
607                for file in os.listdir(site_packages)
608                if file.startswith("pip-") and file.endswith(".dist-info")
609            ),
610            None,
611        )
612        if not pip_dist_info:
613            raise Exception("Failed to find pip dist-info in new virtualenv")
614
615        with open(os.path.join(site_packages, pip_dist_info, "INSTALLER"), "w") as file:
616            file.write("mach")
617
618    def _run_pip(self, args, **kwargs):
619        kwargs.setdefault("check", True)
620
621        env = os.environ.copy()
622        env.setdefault("ARCHFLAGS", get_archflags())
623
624        # It's tempting to call pip natively via pip.main(). However,
625        # the current Python interpreter may not be the virtualenv python.
626        # This will confuse pip and cause the package to attempt to install
627        # against the executing interpreter. By creating a new process, we
628        # force the virtualenv's interpreter to be used and all is well.
629        # It /might/ be possible to cheat and set sys.executable to
630        # self.python_path. However, this seems more risk than it's worth.
631        pip = os.path.join(self.bin_path, "pip")
632        return subprocess.run(
633            [pip] + args, cwd=self.topsrcdir, env=env, universal_newlines=True, **kwargs
634        )
635
636
637def get_archflags():
638    # distutils will use the architecture of the running Python instance when building packages.
639    # However, it's possible for the Xcode Python to be a universal binary (x86_64 and
640    # arm64) without the associated macOS SDK supporting arm64, thereby causing a build
641    # failure. To avoid this, we explicitly influence the build to only target a single
642    # architecture - our current architecture.
643    return "-arch {}".format(platform.machine())
644
645
646def verify_python_version(log_handle):
647    """Ensure the current version of Python is sufficient."""
648    from distutils.version import LooseVersion
649
650    major, minor, micro = sys.version_info[:3]
651    minimum_python_versions = {2: LooseVersion("2.7.3"), 3: LooseVersion("3.6.0")}
652    our = LooseVersion("%d.%d.%d" % (major, minor, micro))
653
654    if major not in minimum_python_versions or our < minimum_python_versions[major]:
655        log_handle.write("One of the following Python versions are required:\n")
656        for minver in minimum_python_versions.values():
657            log_handle.write("* Python %s or greater\n" % minver)
658        log_handle.write("You are running Python %s.\n" % our)
659
660        if os.name in ("nt", "ce"):
661            log_handle.write(UPGRADE_WINDOWS)
662        else:
663            log_handle.write(UPGRADE_OTHER)
664
665        sys.exit(1)
666
667
668if __name__ == "__main__":
669    verify_python_version(sys.stdout)
670
671    if len(sys.argv) < 2:
672        print("Too few arguments", file=sys.stderr)
673        sys.exit(1)
674
675    parser = argparse.ArgumentParser()
676    parser.add_argument("topsrcdir")
677    parser.add_argument("virtualenv_path")
678    parser.add_argument("manifest_path")
679    parser.add_argument("--populate-local-paths", action="store_true")
680
681    if sys.argv[1] == "populate":
682        # This should only be called internally.
683        populate = True
684        opts = parser.parse_args(sys.argv[2:])
685    else:
686        populate = False
687        opts = parser.parse_args(sys.argv[1:])
688
689    manager = VirtualenvManager(
690        opts.topsrcdir,
691        opts.virtualenv_path,
692        sys.stdout,
693        opts.manifest_path,
694        populate_local_paths=opts.populate_local_paths,
695    )
696
697    if populate:
698        manager.populate()
699    else:
700        manager.ensure()
701