wheels.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. import importlib
  2. import os
  3. import re
  4. import shlex
  5. import subprocess
  6. import sys
  7. from datetime import datetime
  8. import tempfile
  9. import time
  10. import urllib.request
  11. from typing import Optional, List, Tuple
  12. from ray_release.config import DEFAULT_PYTHON_VERSION, parse_python_version
  13. from ray_release.template import set_test_env_var
  14. from ray_release.exception import (
  15. RayWheelsUnspecifiedError,
  16. RayWheelsNotFoundError,
  17. RayWheelsTimeoutError,
  18. ReleaseTestSetupError,
  19. )
  20. from ray_release.logger import logger
  21. from ray_release.util import url_exists, python_version_str, resolve_url
  22. from ray_release.aws import upload_to_s3
  23. DEFAULT_BRANCH = "master"
  24. DEFAULT_GIT_OWNER = "ray-project"
  25. DEFAULT_GIT_PACKAGE = "ray"
  26. RELEASE_MANUAL_WHEEL_BUCKET = "ray-release-manual-wheels"
  27. REPO_URL_TPL = "https://github.com/{owner}/{package}.git"
  28. INIT_URL_TPL = (
  29. "https://raw.githubusercontent.com/{fork}/{commit}/python/ray/__init__.py"
  30. )
  31. DEFAULT_REPO = REPO_URL_TPL.format(owner=DEFAULT_GIT_OWNER, package=DEFAULT_GIT_PACKAGE)
  32. # Modules to be reloaded after installing a new local ray version
  33. RELOAD_MODULES = ["ray", "ray.job_submission"]
  34. def get_ray_version(repo_url: str, commit: str) -> str:
  35. assert "https://github.com/" in repo_url
  36. _, fork = repo_url.split("https://github.com/", maxsplit=2)
  37. if fork.endswith(".git"):
  38. fork = fork[:-4]
  39. init_url = INIT_URL_TPL.format(fork=fork, commit=commit)
  40. try:
  41. for line in urllib.request.urlopen(init_url):
  42. line = line.decode("utf-8")
  43. if line.startswith("__version__"):
  44. version = line.split(" = ")[1].strip('"\r\n ')
  45. return version
  46. except Exception as e:
  47. raise ReleaseTestSetupError(
  48. f"Couldn't load version info from branch URL: {init_url}"
  49. ) from e
  50. raise RayWheelsNotFoundError(
  51. f"Unable to parse Ray version information for repo {repo_url} "
  52. f"and commit {commit} (please check this URL: {init_url})"
  53. )
  54. def get_latest_commits(
  55. repo_url: str, branch: str = "master", ref: Optional[str] = None
  56. ) -> List[str]:
  57. cur = os.getcwd()
  58. with tempfile.TemporaryDirectory() as tmpdir:
  59. os.chdir(tmpdir)
  60. clone_cmd = [
  61. "git",
  62. "clone",
  63. "--filter=tree:0",
  64. "--no-checkout",
  65. # "--single-branch",
  66. # "--depth=10",
  67. f"--branch={branch}",
  68. repo_url,
  69. tmpdir,
  70. ]
  71. log_cmd = [
  72. "git",
  73. "log",
  74. "-n",
  75. "10",
  76. "--pretty=format:%H",
  77. ]
  78. subprocess.check_output(clone_cmd)
  79. if ref:
  80. subprocess.check_output(["git", "checkout", ref])
  81. commits = (
  82. subprocess.check_output(log_cmd).decode(sys.stdout.encoding).split("\n")
  83. )
  84. os.chdir(cur)
  85. return commits
  86. def get_wheels_filename(
  87. ray_version: str, python_version: Tuple[int, int] = DEFAULT_PYTHON_VERSION
  88. ) -> str:
  89. version_str = python_version_str(python_version)
  90. suffix = "m" if python_version[1] <= 7 else ""
  91. return (
  92. f"ray-{ray_version}-cp{version_str}-cp{version_str}{suffix}-"
  93. f"manylinux2014_x86_64.whl"
  94. )
  95. def parse_wheels_filename(
  96. filename: str,
  97. ) -> Tuple[Optional[str], Optional[Tuple[int, int]]]:
  98. """Parse filename and return Ray version + python version"""
  99. matched = re.search(
  100. r"ray-([0-9a-z\.]+)-cp([0-9]{2,3})-cp([0-9]{2,3})m?-manylinux2014_x86_64\.whl$",
  101. filename,
  102. )
  103. if not matched:
  104. return None, None
  105. ray_version = matched.group(1)
  106. py_version_str = matched.group(2)
  107. try:
  108. python_version = parse_python_version(py_version_str)
  109. except Exception:
  110. return ray_version, None
  111. return ray_version, python_version
  112. def get_ray_wheels_url_from_local_wheel(ray_wheels: str) -> Optional[str]:
  113. """Upload a local wheel file to S3 and return the downloadable URI
  114. The uploaded object will have local user and current timestamp encoded
  115. in the upload key path, e.g.:
  116. "ubuntu_2022_01_01_23:59:99/ray-3.0.0.dev0-cp37-cp37m-manylinux_x86_64.whl"
  117. Args:
  118. ray_wheels: File path with `file://` prefix.
  119. Return:
  120. Downloadable HTTP URL to the uploaded wheel on S3.
  121. """
  122. wheel_path = ray_wheels[len("file://") :]
  123. wheel_name = os.path.basename(wheel_path)
  124. if not os.path.exists(wheel_path):
  125. logger.error(f"Local wheel file: {wheel_path} not found")
  126. return None
  127. bucket = RELEASE_MANUAL_WHEEL_BUCKET
  128. unique_dest_path_prefix = (
  129. f'{os.getlogin()}_{datetime.now().strftime("%Y_%m_%d_%H:%M:%S")}'
  130. )
  131. key_path = f"{unique_dest_path_prefix}/{wheel_name}"
  132. return upload_to_s3(wheel_path, bucket, key_path)
  133. def get_ray_wheels_url(
  134. repo_url: str,
  135. branch: str,
  136. commit: str,
  137. ray_version: str,
  138. python_version: Tuple[int, int] = DEFAULT_PYTHON_VERSION,
  139. ) -> str:
  140. if not repo_url.startswith("https://github.com/ray-project/ray"):
  141. return (
  142. f"https://ray-ci-artifact-pr-public.s3.amazonaws.com/"
  143. f"{commit}/tmp/artifacts/.whl/"
  144. f"{get_wheels_filename(ray_version, python_version)}"
  145. )
  146. # Else, ray repo
  147. return (
  148. f"https://s3-us-west-2.amazonaws.com/ray-wheels/"
  149. f"{branch}/{commit}/{get_wheels_filename(ray_version, python_version)}"
  150. )
  151. def wait_for_url(
  152. url, timeout: float = 300.0, check_time: float = 30.0, status_time: float = 60.0
  153. ) -> str:
  154. start_time = time.monotonic()
  155. timeout_at = start_time + timeout
  156. next_status = start_time + status_time
  157. logger.info(f"Waiting up to {timeout} seconds until URL is available " f"({url})")
  158. while not url_exists(url):
  159. now = time.monotonic()
  160. if now >= timeout_at:
  161. raise RayWheelsTimeoutError(
  162. f"Time out when waiting for URL to be available: {url}"
  163. )
  164. if now >= next_status:
  165. logger.info(
  166. f"... still waiting for URL {url} "
  167. f"({int(now - start_time)} seconds) ..."
  168. )
  169. next_status += status_time
  170. # Sleep `check_time` sec before next check.
  171. time.sleep(check_time)
  172. logger.info(f"URL is now available: {url}")
  173. return url
  174. def find_and_wait_for_ray_wheels_url(
  175. ray_wheels: Optional[str] = None,
  176. python_version: Tuple[int, int] = DEFAULT_PYTHON_VERSION,
  177. timeout: float = 3600.0,
  178. ) -> str:
  179. ray_wheels_url = find_ray_wheels_url(ray_wheels, python_version=python_version)
  180. logger.info(f"Using Ray wheels URL: {ray_wheels_url}")
  181. return wait_for_url(ray_wheels_url, timeout=timeout)
  182. def get_buildkite_repo_branch() -> Tuple[str, str]:
  183. if "BUILDKITE_BRANCH" not in os.environ:
  184. return DEFAULT_REPO, DEFAULT_BRANCH
  185. branch_str = os.environ["BUILDKITE_BRANCH"]
  186. # BUILDKITE_PULL_REQUEST_REPO can be empty string, use `or` to catch this
  187. repo_url = os.environ.get("BUILDKITE_PULL_REQUEST_REPO", None) or os.environ.get(
  188. "BUILDKITE_REPO", DEFAULT_REPO
  189. )
  190. if ":" in branch_str:
  191. # If the branch is user:branch, we split into user, branch
  192. owner, branch = branch_str.split(":", maxsplit=1)
  193. # If this is a PR, the repo_url is already set via env variable.
  194. # We only construct our own repo url if this is a branch build.
  195. if not os.environ.get("BUILDKITE_PULL_REQUEST_REPO"):
  196. repo_url = f"https://github.com/{owner}/ray.git"
  197. else:
  198. branch = branch_str
  199. repo_url = repo_url.replace("git://", "https://")
  200. return repo_url, branch
  201. def find_ray_wheels_url(
  202. ray_wheels: Optional[str] = None,
  203. python_version: Tuple[int, int] = DEFAULT_PYTHON_VERSION,
  204. ) -> str:
  205. if not ray_wheels:
  206. # If no wheels are specified, default to BUILDKITE_COMMIT
  207. commit = os.environ.get("BUILDKITE_COMMIT", None)
  208. if not commit:
  209. raise RayWheelsUnspecifiedError(
  210. "No Ray wheels specified. Pass `--ray-wheels` or set "
  211. "`BUILDKITE_COMMIT` environment variable. "
  212. "Hint: You can use `-ray-wheels master` to fetch "
  213. "the latest available master wheels."
  214. )
  215. repo_url, branch = get_buildkite_repo_branch()
  216. if not re.match(r"\b([a-f0-9]{40})\b", commit):
  217. # commit is symbolic, like HEAD
  218. latest_commits = get_latest_commits(repo_url, branch, ref=commit)
  219. commit = latest_commits[0]
  220. ray_version = get_ray_version(repo_url, commit)
  221. set_test_env_var("RAY_COMMIT", commit)
  222. set_test_env_var("RAY_BRANCH", branch)
  223. set_test_env_var("RAY_VERSION", ray_version)
  224. return get_ray_wheels_url(repo_url, branch, commit, ray_version, python_version)
  225. # If this is a local wheel file.
  226. if ray_wheels.startswith("file://"):
  227. logger.info(f"Getting wheel url from local wheel file: {ray_wheels}")
  228. ray_wheels_url = get_ray_wheels_url_from_local_wheel(ray_wheels)
  229. if ray_wheels_url is None:
  230. raise RayWheelsNotFoundError(
  231. f"Couldn't get wheel urls from local wheel file({ray_wheels}) by "
  232. "uploading it to S3."
  233. )
  234. return ray_wheels_url
  235. # If this is a URL, return
  236. if ray_wheels.startswith("https://") or ray_wheels.startswith("http://"):
  237. ray_wheels_url = maybe_rewrite_wheels_url(
  238. ray_wheels, python_version=python_version
  239. )
  240. return ray_wheels_url
  241. # Else, this is either a commit hash, a branch name, or a combination
  242. # with a repo, e.g. ray-project:master or ray-project:<commit>
  243. if ":" in ray_wheels:
  244. # Repo is specified
  245. owner_or_url, commit_or_branch = ray_wheels.split(":")
  246. else:
  247. # Repo is not specified, use ray-project instead
  248. owner_or_url = DEFAULT_GIT_OWNER
  249. commit_or_branch = ray_wheels
  250. # Construct repo URL for cloning
  251. if "https://" in owner_or_url:
  252. # Already is a repo URL
  253. repo_url = owner_or_url
  254. else:
  255. repo_url = REPO_URL_TPL.format(owner=owner_or_url, package=DEFAULT_GIT_PACKAGE)
  256. # Todo: This is not ideal as branches that mimic a SHA1 hash
  257. # will also match this.
  258. if not re.match(r"\b([a-f0-9]{40})\b", commit_or_branch):
  259. # This is a branch
  260. branch = commit_or_branch
  261. latest_commits = get_latest_commits(repo_url, branch)
  262. # Let's assume the ray version is constant over these commits
  263. # (otherwise just move it into the for loop)
  264. ray_version = get_ray_version(repo_url, latest_commits[0])
  265. for commit in latest_commits:
  266. try:
  267. wheels_url = get_ray_wheels_url(
  268. repo_url, branch, commit, ray_version, python_version
  269. )
  270. except Exception as e:
  271. logger.info(f"Commit not found for PR: {e}")
  272. continue
  273. if url_exists(wheels_url):
  274. set_test_env_var("RAY_COMMIT", commit)
  275. return wheels_url
  276. else:
  277. logger.info(
  278. f"Wheels URL for commit {commit} does not exist: " f"{wheels_url}"
  279. )
  280. raise RayWheelsNotFoundError(
  281. f"Couldn't find latest available wheels for repo "
  282. f"{repo_url}, branch {branch} (version {ray_version}). "
  283. f"Try again later or check Buildkite logs if wheel builds "
  284. f"failed."
  285. )
  286. # Else, this is a commit
  287. commit = commit_or_branch
  288. ray_version = get_ray_version(repo_url, commit)
  289. branch = os.environ.get("BUILDKITE_BRANCH", DEFAULT_BRANCH)
  290. wheels_url = get_ray_wheels_url(
  291. repo_url, branch, commit, ray_version, python_version
  292. )
  293. set_test_env_var("RAY_COMMIT", commit)
  294. set_test_env_var("RAY_BRANCH", branch)
  295. set_test_env_var("RAY_VERSION", ray_version)
  296. return wheels_url
  297. def maybe_rewrite_wheels_url(
  298. ray_wheels_url: str, python_version: Tuple[int, int]
  299. ) -> str:
  300. full_url = resolve_url(ray_wheels_url)
  301. # If the version is matching, just return the full url
  302. if is_wheels_url_matching_ray_verison(
  303. ray_wheels_url=full_url, python_version=python_version
  304. ):
  305. return full_url
  306. # Try to parse the version from the filename / URL
  307. parsed_ray_version, parsed_python_version = parse_wheels_filename(full_url)
  308. if not parsed_ray_version or not python_version:
  309. # If we can't parse, we don't know the version, so we raise a warning
  310. logger.warning(
  311. f"The passed Ray wheels URL may not work with the python version "
  312. f"used in this test! Got python version {python_version} and "
  313. f"wheels URL: {ray_wheels_url}."
  314. )
  315. return full_url
  316. # If we parsed this and the python version is different from the actual version,
  317. # try to rewrite the URL
  318. current_filename = get_wheels_filename(parsed_ray_version, parsed_python_version)
  319. rewritten_filename = get_wheels_filename(parsed_ray_version, python_version)
  320. new_url = full_url.replace(current_filename, rewritten_filename)
  321. if new_url != full_url:
  322. logger.warning(
  323. f"The passed Ray wheels URL were for a different python version than "
  324. f"used in this test! Found python version {parsed_python_version} "
  325. f"but expected {python_version}. The wheels URL was re-written to "
  326. f"{new_url}."
  327. )
  328. return new_url
  329. def is_wheels_url_matching_ray_verison(
  330. ray_wheels_url: str, python_version: Tuple[int, int]
  331. ) -> bool:
  332. """Return True if the wheels URL wheel matches the supplied python version."""
  333. expected_filename = get_wheels_filename(
  334. ray_version="xxx", python_version=python_version
  335. )
  336. expected_filename = expected_filename[7:] # Cut ray-xxx
  337. return ray_wheels_url.endswith(expected_filename)
  338. def install_matching_ray_locally(ray_wheels: Optional[str]):
  339. if not ray_wheels:
  340. logger.warning(
  341. "No Ray wheels found - can't install matching Ray wheels locally!"
  342. )
  343. return
  344. assert "manylinux2014_x86_64" in ray_wheels, ray_wheels
  345. if sys.platform == "darwin":
  346. platform = "macosx_10_15_intel"
  347. elif sys.platform == "win32":
  348. platform = "win_amd64"
  349. else:
  350. platform = "manylinux2014_x86_64"
  351. ray_wheels = ray_wheels.replace("manylinux2014_x86_64", platform)
  352. logger.info(f"Installing matching Ray wheels locally: {ray_wheels}")
  353. subprocess.check_output(
  354. "pip uninstall -y ray", shell=True, env=os.environ, text=True
  355. )
  356. subprocess.check_output(
  357. f"pip install -U {shlex.quote(ray_wheels)}",
  358. shell=True,
  359. env=os.environ,
  360. text=True,
  361. )
  362. for module_name in RELOAD_MODULES:
  363. if module_name in sys.modules:
  364. importlib.reload(sys.modules[module_name])
  365. def parse_commit_from_wheel_url(url: str) -> str:
  366. # url is expected to be in the format of
  367. # https://s3-us-west-2.amazonaws.com/ray-wheels/master/0e0c15065507f01e8bfe78e49b0d0de063f81164/ray-3.0.0.dev0-cp37-cp37m-manylinux2014_x86_64.whl # noqa
  368. regex = r"/([0-9a-f]{40})/"
  369. match = re.search(regex, url)
  370. if match:
  371. return match.group(1)