wheels.py 16 KB

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