test.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716
  1. import asyncio
  2. import concurrent.futures
  3. import enum
  4. import os
  5. import platform
  6. import subprocess
  7. import json
  8. import time
  9. from itertools import chain
  10. from typing import Awaitable, Optional, List, Dict, Set
  11. from dataclasses import dataclass
  12. import aioboto3
  13. import boto3
  14. from botocore.exceptions import ClientError
  15. from github import Repository
  16. from ray_release.aws import s3_put_rayci_test_data
  17. from ray_release.configs.global_config import get_global_config
  18. from ray_release.result import (
  19. ResultStatus,
  20. Result,
  21. )
  22. from ray_release.logger import logger
  23. from ray_release.util import (
  24. dict_hash,
  25. get_read_state_machine_aws_bucket,
  26. get_write_state_machine_aws_bucket,
  27. )
  28. MICROCHECK_COMMAND = "@microcheck"
  29. AWS_TEST_KEY = "ray_tests"
  30. AWS_TEST_RESULT_KEY = "ray_test_results"
  31. DEFAULT_PYTHON_VERSION = tuple(
  32. int(v) for v in os.environ.get("RELEASE_PY", "3.9").split(".")
  33. )
  34. DATAPLANE_ECR_REPO = "anyscale/ray"
  35. DATAPLANE_ECR_ML_REPO = "anyscale/ray-ml"
  36. MACOS_TEST_PREFIX = "darwin:"
  37. LINUX_TEST_PREFIX = "linux:"
  38. WINDOWS_TEST_PREFIX = "windows:"
  39. MACOS_BISECT_DAILY_RATE_LIMIT = 3
  40. LINUX_BISECT_DAILY_RATE_LIMIT = 3
  41. WINDOWS_BISECT_DAILY_RATE_LIMIT = 3
  42. BISECT_DAILY_RATE_LIMIT = 10
  43. _asyncio_thread_pool = concurrent.futures.ThreadPoolExecutor()
  44. def _convert_env_list_to_dict(env_list: List[str]) -> Dict[str, str]:
  45. env_dict = {}
  46. for env in env_list:
  47. # an env can be "a=b" or just "a"
  48. eq_pos = env.find("=")
  49. if eq_pos < 0:
  50. env_dict[env] = os.environ.get(env, "")
  51. else:
  52. env_dict[env[:eq_pos]] = env[eq_pos + 1 :]
  53. return env_dict
  54. class TestState(enum.Enum):
  55. """
  56. Overall state of the test
  57. """
  58. JAILED = "jailed"
  59. FAILING = "failing"
  60. FLAKY = "flaky"
  61. CONSITENTLY_FAILING = "consistently_failing"
  62. PASSING = "passing"
  63. class TestType(enum.Enum):
  64. """
  65. Type of the test
  66. """
  67. RELEASE_TEST = "release_test"
  68. MACOS_TEST = "macos_test"
  69. LINUX_TEST = "linux_test"
  70. WINDOWS_TEST = "windows_test"
  71. @dataclass
  72. class TestResult:
  73. status: str
  74. commit: str
  75. branch: str
  76. url: str
  77. timestamp: int
  78. pull_request: str
  79. rayci_step_id: str
  80. @classmethod
  81. def from_result(cls, result: Result):
  82. return cls(
  83. status=result.status,
  84. commit=os.environ.get("BUILDKITE_COMMIT", ""),
  85. branch=os.environ.get("BUILDKITE_BRANCH", ""),
  86. url=result.buildkite_url,
  87. timestamp=int(time.time() * 1000),
  88. pull_request=os.environ.get("BUILDKITE_PULL_REQUEST", ""),
  89. rayci_step_id=os.environ.get("RAYCI_STEP_ID", ""),
  90. )
  91. @classmethod
  92. def from_bazel_event(cls, event: dict):
  93. return cls.from_result(
  94. Result(
  95. status=ResultStatus.SUCCESS.value
  96. if event["testResult"]["status"] == "PASSED"
  97. else ResultStatus.ERROR.value,
  98. buildkite_url=(
  99. f"{os.environ.get('BUILDKITE_BUILD_URL')}"
  100. f"#{os.environ.get('BUILDKITE_JOB_ID')}"
  101. ),
  102. )
  103. )
  104. @classmethod
  105. def from_dict(cls, result: dict):
  106. return cls(
  107. status=result["status"],
  108. commit=result["commit"],
  109. branch=result.get("branch", ""),
  110. url=result["url"],
  111. timestamp=result["timestamp"],
  112. pull_request=result.get("pull_request", ""),
  113. rayci_step_id=result.get("rayci_step_id", ""),
  114. )
  115. def is_failing(self) -> bool:
  116. return not self.is_passing()
  117. def is_passing(self) -> bool:
  118. return self.status == ResultStatus.SUCCESS.value
  119. class Test(dict):
  120. """A class represents a test to run on buildkite"""
  121. KEY_GITHUB_ISSUE_NUMBER = "github_issue_number"
  122. KEY_BISECT_BUILD_NUMBER = "bisect_build_number"
  123. KEY_BISECT_BLAMED_COMMIT = "bisect_blamed_commit"
  124. # a test is high impact if it catches regressions frequently
  125. KEY_IS_HIGH_IMPACT = "is_high_impact"
  126. def __init__(self, *args, **kwargs):
  127. super().__init__(*args, **kwargs)
  128. self.test_results = None
  129. @classmethod
  130. def from_bazel_event(cls, event: dict, team: str):
  131. name = event["id"]["testResult"]["label"]
  132. system = platform.system().lower()
  133. return cls(
  134. {
  135. "name": f"{system}:{name}",
  136. "team": team,
  137. }
  138. )
  139. @classmethod
  140. def gen_from_name(cls, name: str):
  141. tests = [
  142. test
  143. for test in Test.gen_from_s3(cls._get_s3_name(name))
  144. if test["name"] == name
  145. ]
  146. return tests[0] if tests else None
  147. @classmethod
  148. def gen_from_s3(cls, prefix: str):
  149. """
  150. Obtain all tests whose names start with the given prefix from s3
  151. """
  152. bucket = get_read_state_machine_aws_bucket()
  153. s3_client = boto3.client("s3")
  154. pages = s3_client.get_paginator("list_objects_v2").paginate(
  155. Bucket=bucket,
  156. Prefix=f"{AWS_TEST_KEY}/{prefix}",
  157. )
  158. files = chain.from_iterable([page.get("Contents", []) for page in pages])
  159. return [
  160. Test(
  161. json.loads(
  162. s3_client.get_object(Bucket=bucket, Key=file["Key"])
  163. .get("Body")
  164. .read()
  165. .decode("utf-8")
  166. )
  167. )
  168. for file in files
  169. ]
  170. @classmethod
  171. def gen_microcheck_step_ids(cls, prefix: str, bazel_workspace_dir: str) -> Set[str]:
  172. """
  173. This function is used to get the buildkite step ids of the microcheck tests
  174. with the given test prefix. This is used to determine the buildkite steps in
  175. the microcheck pipeline.
  176. """
  177. step_ids = set()
  178. test_targets = cls.gen_microcheck_tests(prefix, bazel_workspace_dir)
  179. for test_target in test_targets:
  180. test = cls.gen_from_name(f"{prefix}{test_target}")
  181. if not test:
  182. continue
  183. recent_results = test.get_test_results()
  184. if not recent_results:
  185. continue
  186. test_step_ids = {
  187. result.rayci_step_id
  188. for result in recent_results
  189. if result.commit == recent_results[0].commit and result.rayci_step_id
  190. }
  191. if test_step_ids and not step_ids.intersection(test_step_ids):
  192. step_ids.add(sorted(test_step_ids)[0])
  193. return step_ids
  194. @classmethod
  195. def gen_microcheck_tests(
  196. cls, prefix: str, bazel_workspace_dir: str, team: Optional[str] = None
  197. ) -> Set[str]:
  198. """
  199. Obtain all microcheck tests with the given prefix
  200. """
  201. high_impact_tests = Test._gen_high_impact_tests(prefix, team)
  202. changed_tests = Test._get_changed_tests(bazel_workspace_dir)
  203. human_specified_tests = Test._get_human_specified_tests(bazel_workspace_dir)
  204. return high_impact_tests.union(changed_tests, human_specified_tests)
  205. @classmethod
  206. def _gen_high_impact_tests(
  207. cls, prefix: str, team: Optional[str] = None
  208. ) -> Set[str]:
  209. """
  210. Obtain all high impact tests with the given prefix
  211. """
  212. high_impact_tests = [
  213. test for test in cls.gen_from_s3(prefix) if test.is_high_impact()
  214. ]
  215. if team:
  216. high_impact_tests = [
  217. test for test in high_impact_tests if test.get_oncall() == team
  218. ]
  219. return {test.get_target() for test in high_impact_tests}
  220. @classmethod
  221. def _get_human_specified_tests(cls, bazel_workspace_dir: str) -> Set[str]:
  222. """
  223. Get all test targets that are specified by humans
  224. """
  225. base = os.environ.get("BUILDKITE_PULL_REQUEST_BASE_BRANCH")
  226. head = os.environ.get("BUILDKITE_COMMIT")
  227. if not base or not head:
  228. # if not in a PR, return an empty set
  229. return set()
  230. tests = set()
  231. messages = subprocess.check_output(
  232. ["git", "rev-list", "--format=%b", f"origin/{base}...{head}"],
  233. cwd=bazel_workspace_dir,
  234. )
  235. for message in messages.decode().splitlines():
  236. if not message.startswith(MICROCHECK_COMMAND):
  237. continue
  238. tests = tests.union(message[len(MICROCHECK_COMMAND) :].strip().split(" "))
  239. return tests
  240. @classmethod
  241. def _get_changed_tests(cls, bazel_workspace_dir: str) -> Set[str]:
  242. """
  243. Get all changed tests in the current PR
  244. """
  245. return set(
  246. chain.from_iterable(
  247. [
  248. cls._get_test_targets_per_file(file, bazel_workspace_dir)
  249. for file in cls._get_changed_files(bazel_workspace_dir)
  250. ]
  251. )
  252. )
  253. @classmethod
  254. def _get_changed_files(cls, bazel_workspace_dir: str) -> Set[str]:
  255. """
  256. Get all changed files in the current PR
  257. """
  258. base = os.environ.get("BUILDKITE_PULL_REQUEST_BASE_BRANCH")
  259. head = os.environ.get("BUILDKITE_COMMIT")
  260. if not base or not head:
  261. # if not in a PR, return an empty set
  262. return set()
  263. changes = subprocess.check_output(
  264. ["git", "diff", "--name-only", f"origin/{base}...{head}"],
  265. cwd=bazel_workspace_dir,
  266. )
  267. return {
  268. file.strip() for file in changes.decode().splitlines() if file is not None
  269. }
  270. @classmethod
  271. def _get_test_targets_per_file(
  272. cls, file: str, bazel_workspace_dir: str
  273. ) -> Set[str]:
  274. """
  275. Get the test target from a file path
  276. """
  277. try:
  278. package = (
  279. subprocess.check_output(
  280. ["bazel", "query", file], cwd=bazel_workspace_dir
  281. )
  282. .decode()
  283. .strip()
  284. )
  285. if not package:
  286. return set()
  287. targets = subprocess.check_output(
  288. ["bazel", "query", f"tests(attr('srcs', {package}, //...))"],
  289. cwd=bazel_workspace_dir,
  290. )
  291. targets = {
  292. target.strip()
  293. for target in targets.decode().splitlines()
  294. if target is not None
  295. }
  296. return targets
  297. except subprocess.CalledProcessError:
  298. return set()
  299. def is_jailed_with_open_issue(self, ray_github: Repository) -> bool:
  300. """
  301. Returns whether this test is jailed with open issue.
  302. """
  303. # is jailed
  304. state = self.get_state()
  305. if state != TestState.JAILED:
  306. return False
  307. # has open issue
  308. issue_number = self.get(self.KEY_GITHUB_ISSUE_NUMBER)
  309. if issue_number is None:
  310. return False
  311. issue = ray_github.get_issue(issue_number)
  312. return issue.state == "open"
  313. def is_stable(self) -> bool:
  314. """
  315. Returns whether this test is stable.
  316. """
  317. return self.get("stable", True)
  318. def is_gce(self) -> bool:
  319. """
  320. Returns whether this test is running on GCE.
  321. """
  322. return self.get("env") == "gce"
  323. def is_high_impact(self) -> bool:
  324. # a test is high impact if it catches regressions frequently, this field is
  325. # populated by the determine_microcheck_tests.py script
  326. return self.get(self.KEY_IS_HIGH_IMPACT, None) == "true"
  327. def get_test_type(self) -> TestType:
  328. test_name = self.get_name()
  329. if test_name.startswith(MACOS_TEST_PREFIX):
  330. return TestType.MACOS_TEST
  331. if test_name.startswith(LINUX_TEST_PREFIX):
  332. return TestType.LINUX_TEST
  333. if test_name.startswith(WINDOWS_TEST_PREFIX):
  334. return TestType.WINDOWS_TEST
  335. return TestType.RELEASE_TEST
  336. def get_bisect_daily_rate_limit(self) -> int:
  337. test_type = self.get_test_type()
  338. if test_type == TestType.MACOS_TEST:
  339. return MACOS_BISECT_DAILY_RATE_LIMIT
  340. if test_type == TestType.LINUX_TEST:
  341. return LINUX_BISECT_DAILY_RATE_LIMIT
  342. if test_type == TestType.WINDOWS_TEST:
  343. return WINDOWS_BISECT_DAILY_RATE_LIMIT
  344. return BISECT_DAILY_RATE_LIMIT
  345. def get_byod_type(self) -> Optional[str]:
  346. """
  347. Returns the type of the BYOD cluster.
  348. """
  349. return self["cluster"]["byod"].get("type", "cpu")
  350. def get_byod_post_build_script(self) -> Optional[str]:
  351. """
  352. Returns the post-build script for the BYOD cluster.
  353. """
  354. return self["cluster"]["byod"].get("post_build_script")
  355. def get_byod_runtime_env(self) -> Dict[str, str]:
  356. """
  357. Returns the runtime environment variables for the BYOD cluster.
  358. """
  359. default = {
  360. "RAY_BACKEND_LOG_JSON": "1",
  361. # Logs the full stack trace from Ray Data in case of exception,
  362. # which is useful for debugging failures.
  363. "RAY_DATA_LOG_INTERNAL_STACK_TRACE_TO_STDOUT": "1",
  364. # To make ray data compatible across multiple pyarrow versions.
  365. "RAY_DATA_AUTOLOAD_PYEXTENSIONTYPE": "1",
  366. }
  367. default.update(
  368. _convert_env_list_to_dict(self["cluster"]["byod"].get("runtime_env", []))
  369. )
  370. return default
  371. def get_byod_pips(self) -> List[str]:
  372. """
  373. Returns the list of pips for the BYOD cluster.
  374. """
  375. return self["cluster"]["byod"].get("pip", [])
  376. def get_name(self) -> str:
  377. """
  378. Returns the name of the test.
  379. """
  380. return self["name"]
  381. def get_target(self) -> str:
  382. test_type = self.get_test_type()
  383. test_name = self.get_name()
  384. if test_type == TestType.MACOS_TEST:
  385. return test_name[len(MACOS_TEST_PREFIX) :]
  386. if test_type == TestType.LINUX_TEST:
  387. return test_name[len(LINUX_TEST_PREFIX) :]
  388. if test_type == TestType.WINDOWS_TEST:
  389. return test_name[len(WINDOWS_TEST_PREFIX) :]
  390. return test_name
  391. @classmethod
  392. def _get_s3_name(cls, test_name: str) -> str:
  393. """
  394. Returns the name of the test for s3. Since '/' is not allowed in s3 key,
  395. replace it with '_'.
  396. """
  397. return test_name.replace("/", "_")
  398. def get_oncall(self) -> str:
  399. """
  400. Returns the oncall for the test.
  401. """
  402. return self["team"]
  403. def update_from_s3(self, force_branch_bucket: bool = True) -> None:
  404. """
  405. Update test object with data fields that exist only on s3
  406. """
  407. try:
  408. data = (
  409. boto3.client("s3")
  410. .get_object(
  411. Bucket=get_read_state_machine_aws_bucket(),
  412. Key=f"{AWS_TEST_KEY}/{self._get_s3_name(self.get_name())}.json",
  413. )
  414. .get("Body")
  415. .read()
  416. .decode("utf-8")
  417. )
  418. except ClientError as e:
  419. logger.warning(f"Failed to update data for {self.get_name()} from s3: {e}")
  420. return
  421. for key, value in json.loads(data).items():
  422. if key not in self:
  423. self[key] = value
  424. def get_state(self) -> TestState:
  425. """
  426. Returns the state of the test.
  427. """
  428. return TestState(self.get("state", TestState.PASSING.value))
  429. def set_state(self, state: TestState) -> None:
  430. """
  431. Sets the state of the test.
  432. """
  433. self["state"] = state.value
  434. def get_python_version(self) -> str:
  435. """
  436. Returns the python version to use for this test. If not specified, use
  437. the default python version.
  438. """
  439. return self.get("python", ".".join(str(v) for v in DEFAULT_PYTHON_VERSION))
  440. def get_byod_base_image_tag(self) -> str:
  441. """
  442. Returns the byod image tag to use for this test.
  443. """
  444. byod_image_tag = os.environ.get("RAY_IMAGE_TAG")
  445. if byod_image_tag:
  446. # Use the image tag specified in the environment variable.
  447. # TODO(can): this is a temporary backdoor that should be removed
  448. # once civ2 is fully rolled out.
  449. return byod_image_tag
  450. commit = os.environ.get(
  451. "COMMIT_TO_TEST",
  452. os.environ["BUILDKITE_COMMIT"],
  453. )
  454. branch = os.environ.get(
  455. "BRANCH_TO_TEST",
  456. os.environ["BUILDKITE_BRANCH"],
  457. )
  458. pr = os.environ.get("BUILDKITE_PULL_REQUEST", "false")
  459. ray_version = commit[:6]
  460. if pr != "false":
  461. ray_version = f"pr-{pr}.{ray_version}"
  462. elif branch.startswith("releases/"):
  463. release_name = branch[len("releases/") :]
  464. ray_version = f"{release_name}.{ray_version}"
  465. python_version = f"py{self.get_python_version().replace('.', '')}"
  466. return f"{ray_version}-{python_version}-{self.get_byod_type()}"
  467. def get_byod_image_tag(self) -> str:
  468. """
  469. Returns the byod custom image tag to use for this test.
  470. """
  471. if not self.require_custom_byod_image():
  472. return self.get_byod_base_image_tag()
  473. custom_info = {
  474. "post_build_script": self.get_byod_post_build_script(),
  475. }
  476. return f"{self.get_byod_base_image_tag()}-{dict_hash(custom_info)}"
  477. def use_byod_ml_image(self) -> bool:
  478. """Returns whether to use the ML image for this test."""
  479. return self.get_byod_type() == "gpu"
  480. def get_byod_repo(self) -> str:
  481. """
  482. Returns the byod repo to use for this test.
  483. """
  484. if self.use_byod_ml_image():
  485. return DATAPLANE_ECR_ML_REPO
  486. return DATAPLANE_ECR_REPO
  487. def get_byod_ecr(self) -> str:
  488. """
  489. Returns the anyscale byod ecr to use for this test.
  490. """
  491. if self.is_gce():
  492. return get_global_config()["byod_gcp_cr"]
  493. byod_ecr = get_global_config()["byod_aws_cr"]
  494. if byod_ecr:
  495. return byod_ecr
  496. return get_global_config()["byod_ecr"]
  497. def get_ray_image(self) -> str:
  498. """
  499. Returns the ray docker image to use for this test.
  500. """
  501. config = get_global_config()
  502. repo = self.get_byod_repo()
  503. if repo == DATAPLANE_ECR_REPO:
  504. repo_name = config["byod_ray_cr_repo"]
  505. elif repo == DATAPLANE_ECR_ML_REPO:
  506. repo_name = config["byod_ray_ml_cr_repo"]
  507. else:
  508. raise ValueError(f"Unknown repo {repo}")
  509. ecr = config["byod_ray_ecr"]
  510. tag = self.get_byod_base_image_tag()
  511. return f"{ecr}/{repo_name}:{tag}"
  512. def get_anyscale_base_byod_image(self) -> str:
  513. """
  514. Returns the anyscale byod image to use for this test.
  515. """
  516. return (
  517. f"{self.get_byod_ecr()}/"
  518. f"{self.get_byod_repo()}:{self.get_byod_base_image_tag()}"
  519. )
  520. def require_custom_byod_image(self) -> bool:
  521. """
  522. Returns whether this test requires a custom byod image.
  523. """
  524. return self.get_byod_post_build_script() is not None
  525. def get_anyscale_byod_image(self) -> str:
  526. """
  527. Returns the anyscale byod image to use for this test.
  528. """
  529. return (
  530. f"{self.get_byod_ecr()}/"
  531. f"{self.get_byod_repo()}:{self.get_byod_image_tag()}"
  532. )
  533. def get_test_results(
  534. self,
  535. limit: int = 10,
  536. refresh: bool = False,
  537. aws_bucket: str = None,
  538. use_async: bool = False,
  539. ) -> List[TestResult]:
  540. """
  541. Get test result from test object, or s3
  542. :param limit: limit of test results to return
  543. :param refresh: whether to refresh the test results from s3
  544. """
  545. if self.test_results is not None and not refresh:
  546. return self.test_results
  547. bucket = aws_bucket or get_read_state_machine_aws_bucket()
  548. s3_client = boto3.client("s3")
  549. pages = s3_client.get_paginator("list_objects_v2").paginate(
  550. Bucket=bucket,
  551. Prefix=f"{AWS_TEST_RESULT_KEY}/{self._get_s3_name(self.get_name())}-",
  552. )
  553. files = sorted(
  554. chain.from_iterable([page.get("Contents", []) for page in pages]),
  555. key=lambda file: int(file["LastModified"].timestamp()),
  556. reverse=True,
  557. )[:limit]
  558. if use_async:
  559. self.test_results = _asyncio_thread_pool.submit(
  560. lambda: asyncio.run(
  561. self._gen_test_results(bucket, [file["Key"] for file in files])
  562. )
  563. ).result()
  564. else:
  565. self.test_results = [
  566. TestResult.from_dict(
  567. json.loads(
  568. s3_client.get_object(
  569. Bucket=bucket,
  570. Key=file["Key"],
  571. )
  572. .get("Body")
  573. .read()
  574. .decode("utf-8")
  575. )
  576. )
  577. for file in files
  578. ]
  579. return self.test_results
  580. async def _gen_test_results(
  581. self,
  582. bucket: str,
  583. keys: List[str],
  584. ) -> Awaitable[List[TestResult]]:
  585. session = aioboto3.Session()
  586. async with session.client("s3") as s3_client:
  587. return await asyncio.gather(
  588. *[self._gen_test_result(s3_client, bucket, key) for key in keys]
  589. )
  590. async def _gen_test_result(
  591. self,
  592. s3_client: aioboto3.Session.client,
  593. bucket: str,
  594. key: str,
  595. ) -> Awaitable[TestResult]:
  596. object = await s3_client.get_object(Bucket=bucket, Key=key)
  597. object_body = await object["Body"].read()
  598. return TestResult.from_dict(json.loads(object_body.decode("utf-8")))
  599. def persist_result_to_s3(self, result: Result) -> bool:
  600. """
  601. Persist result object to s3
  602. """
  603. self.persist_test_result_to_s3(TestResult.from_result(result))
  604. def persist_test_result_to_s3(self, test_result: TestResult) -> bool:
  605. """
  606. Persist test result object to s3
  607. """
  608. s3_put_rayci_test_data(
  609. Bucket=get_write_state_machine_aws_bucket(),
  610. Key=f"{AWS_TEST_RESULT_KEY}/"
  611. f"{self._get_s3_name(self.get_name())}-{int(time.time() * 1000)}.json",
  612. Body=json.dumps(test_result.__dict__),
  613. )
  614. def persist_to_s3(self) -> bool:
  615. """
  616. Persist test object to s3
  617. """
  618. s3_put_rayci_test_data(
  619. Bucket=get_write_state_machine_aws_bucket(),
  620. Key=f"{AWS_TEST_KEY}/{self._get_s3_name(self.get_name())}.json",
  621. Body=json.dumps(self),
  622. )
  623. class TestDefinition(dict):
  624. """
  625. A class represents a definition of a test, such as test name, group, etc. Comparing
  626. to the test class, there are additional field, for example variations, which can be
  627. used to define several variations of a test.
  628. """
  629. pass