pyactivate 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. #!/usr/bin/env python3
  2. # Copyright Materialize, Inc. and contributors. All rights reserved.
  3. #
  4. # Use of this software is governed by the Business Source License
  5. # included in the LICENSE file at the root of this repository.
  6. #
  7. # As of the Change Date specified in that file, in accordance with
  8. # the Business Source License, use of this software will be governed
  9. # by the Apache License, Version 2.0.
  10. #
  11. # pyactivate — runs a script in the Materialize Python virtualenv.
  12. import logging
  13. import os
  14. import platform
  15. import subprocess
  16. import sys
  17. import venv
  18. from pathlib import Path
  19. from typing import Optional
  20. logger = logging.getLogger("bootstrap")
  21. def main(args: list[str]) -> int:
  22. logging.basicConfig(level=os.environ.get("MZ_DEV_LOG", "WARNING").upper())
  23. logger.debug("args=%s", args)
  24. # Validate Python version.
  25. if sys.hexversion < 0x030a0000:
  26. print("fatal: python v3.10.0+ required", file=sys.stderr)
  27. print(
  28. " hint: you have v{}.{}.{}".format(
  29. sys.version_info.major, sys.version_info.minor, sys.version_info.micro
  30. ),
  31. file=sys.stderr,
  32. )
  33. return 1
  34. root_dir = Path(__file__).parent.parent
  35. py_dir = root_dir / "misc" / "python"
  36. logger.debug("root_dir=%s py_dir=%s", root_dir, py_dir)
  37. # If we're not in the CI builder container, activate a virtualenv with the
  38. # necessary dependencies.
  39. if os.environ.get("MZ_DEV_CI_BUILDER"):
  40. python = "python3"
  41. else:
  42. python = str(activate_venv(py_dir))
  43. logger.debug("python=%s", python)
  44. # Reinvoke with the interpreter from the virtualenv.
  45. py_path = str(py_dir.resolve())
  46. os.environ["PYTHONPATH"] = py_path
  47. os.environ["MYPYPATH"] = f"{py_path}:{py_path}/stubs"
  48. os.environ["MZ_ROOT"] = str(root_dir.resolve())
  49. os.execvp(python, [python, *args])
  50. def activate_venv(py_dir: Path) -> Path:
  51. """Bootstrap and activate a virtualenv at py_dir/venv."""
  52. venv_dir = py_dir / "venv"
  53. stamp_path = venv_dir / "dep_stamp"
  54. bin_dir = venv_dir / "bin"
  55. python = bin_dir / "python"
  56. logger.debug("venv_dir=%s python=%s", venv_dir, python)
  57. # Create a virtualenv, if necessary. virtualenv creation is not atomic, so
  58. # we don't want to assume the presence of a `venv` directory means that we
  59. # have a working virtualenv. Instead we use the presence of the
  60. # `stamp_path`, as that indicates the virtualenv was once working enough to
  61. # have dependencies installed into it.
  62. try:
  63. os.stat(stamp_path)
  64. subprocess.check_call([python, "-c", ""])
  65. except Exception as e:
  66. print("==> Checking for existing virtualenv", file=sys.stderr)
  67. if isinstance(e, FileNotFoundError):
  68. print("no existing virtualenv detected", file=sys.stderr)
  69. else:
  70. # Usually just an indication that the user has upgraded the system
  71. # Python that the virtualenv is referring to. Not important to
  72. # bubble up the error here. If it's persistent, it'll occur in the
  73. # new virtual env and bubble up when we exec later.
  74. print(
  75. "warning: existing virtualenv is unable to execute python; will recreate",
  76. file=sys.stderr
  77. )
  78. logger.info("python exec error: %s", e)
  79. print(f"==> Initializing virtualenv in {venv_dir}", file=sys.stderr)
  80. try:
  81. subprocess.check_call(["uv", "venv", venv_dir])
  82. except FileNotFoundError:
  83. # Install Python into the virtualenv via a symlink rather than copying,
  84. # except on Windows. This matches the behavior of the `python -m venv`
  85. # command line tool. This is important on macOS, where the default
  86. # `symlinks=False` is broken with the system Python.
  87. # See: https://bugs.python.org/issue38705
  88. symlinks = os.name != "nt"
  89. # Work around Debian's packaging of Python, which doesn't include the
  90. # `ensurepip` module that `venv` uses under the hood.
  91. try:
  92. import ensurepip
  93. except ImportError:
  94. raise AssertionError(
  95. "It appears you're on a Debian-derived system. Please install `python3-venv`, otherwise this will fail annoyingly."
  96. )
  97. venv.create(venv_dir, with_pip=True, clear=True, symlinks=symlinks)
  98. # Work around a Debian bug which incorrectly makes pip think that we've
  99. # installed wheel in the virtual environment when we haven't. This is
  100. # a no-op on systems without the bug.
  101. # See: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=959997
  102. for path in venv_dir.glob("share/python-wheels/wheel*"):
  103. path.unlink()
  104. # Work around a Debian bug which incorrectly makes pip think that we've
  105. # installed wheel in the virtual environment when we haven't. This is
  106. # a no-op on systems without the bug.
  107. # See: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=959997
  108. for path in venv_dir.glob("share/python-wheels/wheel*"):
  109. path.unlink()
  110. # The Python that ships with Xcode 12 is broken and attempts to compile
  111. # Python extensions for ARM when it shouldn't. Detect this known-broken
  112. # Python and manually override the architecture to x86_64.
  113. # For context, see https://github.com/gorakhargosh/watchdog/issues/689.
  114. # (The failing package for us is regex, not watchdog, but the underlying
  115. # issue is the same.)
  116. if (
  117. sys.executable == "/Applications/Xcode.app/Contents/Developer/usr/bin/python3"
  118. and platform.machine() == "x86_64"
  119. and platform.python_version() == "3.8.2"
  120. ):
  121. os.environ["ARCHFLAGS"] = "-arch x86_64"
  122. # The version of pip and setuptools installed into the virtualenv by default
  123. # may have bugs like pypa/pip#9138 that cause them to e.g. fail to install
  124. # wheels. So the first thing we do is upgrade these dependencies to known
  125. # versions. This won't help if they're so broken that they can't upgrade
  126. # themselves, but in that case there's nothing we can do.
  127. #
  128. # Note also that we don't ask for PEP 517 until we've installed a version of
  129. # pip that we know supports the flag.
  130. acquire_deps(venv_dir, "core")
  131. acquire_deps(venv_dir, use_pep517=True)
  132. # The virtualenv's `bin` dir takes precedence over all existing PATH
  133. # entries. This is normally handled by the `activate` shell script, but
  134. # we won't be calling that.
  135. os.environ["PATH"] = str(bin_dir) + os.pathsep + os.environ["PATH"]
  136. return python
  137. def acquire_deps(venv_dir: Path, variant: Optional[str] = None, use_pep517: bool = False) -> None:
  138. """Install normal/development dependencies into the virtualenv.
  139. If the use_pep517 flag is set, the `--use-pep517` flag is pased to `pip
  140. install` to force use of new PEP 517-based build systems rather than legacy
  141. setuptools build systems. This prevents pip from printing a scary warning
  142. about "using legacy 'setup.py install'".
  143. See: https://github.com/pypa/pip/issues/8102
  144. """
  145. stamp_path = venv_dir / (f"{variant}_dep_stamp" if variant else "dep_stamp")
  146. # Check when dependencies were last installed.
  147. try:
  148. stamp_mtime = os.path.getmtime(stamp_path)
  149. except FileNotFoundError:
  150. stamp_mtime = 0
  151. logger.debug("stamp_path=%s stamp_mtime=%s", stamp_path, stamp_mtime)
  152. # Check when the requirements file was last modified.
  153. requirements_path = venv_dir.parent / (
  154. f"requirements-{variant}.txt" if variant else "requirements.txt"
  155. )
  156. requirements_mtime = os.path.getmtime(requirements_path)
  157. logger.debug("requirements_path=%s requirements_mtime=%s", requirements_path, requirements_mtime)
  158. # Update dependencies, if necessary.
  159. if stamp_mtime <= requirements_mtime:
  160. print(f"==> Updating {variant + ' ' if variant else ''}dependencies", file=sys.stderr)
  161. try:
  162. subprocess.check_call([
  163. "uv", "pip", "install", "-r", requirements_path,
  164. ],
  165. stdout=sys.stderr,
  166. cwd=venv_dir
  167. )
  168. except FileNotFoundError:
  169. subprocess.check_call([
  170. venv_dir / "bin" / "pip", "install", "-r", requirements_path,
  171. "--disable-pip-version-check",
  172. *(["--use-pep517"] if use_pep517 else []),
  173. ],
  174. stdout=sys.stderr,
  175. )
  176. stamp_path.touch()
  177. if __name__ == "__main__":
  178. sys.exit(main(sys.argv[1:]))