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