lint.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. # Copyright Materialize, Inc. and contributors. All rights reserved.
  2. #
  3. # Use of this software is governed by the Business Source License
  4. # included in the LICENSE file at the root of this repository.
  5. #
  6. # As of the Change Date specified in that file, in accordance with
  7. # the Business Source License, use of this software will be governed
  8. # by the Apache License, Version 2.0.
  9. import argparse
  10. import os
  11. import subprocess
  12. import threading
  13. import time
  14. from datetime import datetime, timedelta
  15. from pathlib import Path
  16. from materialize import MZ_ROOT, buildkite
  17. from materialize.terminal import (
  18. COLOR_ERROR,
  19. COLOR_OK,
  20. STYLE_BOLD,
  21. with_formatting,
  22. with_formattings,
  23. )
  24. MAIN_PATH = MZ_ROOT / "ci" / "test" / "lint-main"
  25. MAIN_CHECKS_PATH = MAIN_PATH / "checks"
  26. CHECK_BEFORE_PATH = MAIN_PATH / "before"
  27. CHECK_AFTER_PATH = MAIN_PATH / "after"
  28. OK = with_formatting("✓", COLOR_OK)
  29. FAIL = with_formattings("✗", [COLOR_ERROR, STYLE_BOLD])
  30. def parse_args() -> argparse.Namespace:
  31. parser = argparse.ArgumentParser(
  32. prog="lint",
  33. formatter_class=argparse.RawDescriptionHelpFormatter,
  34. description="Lint the code",
  35. )
  36. parser.add_argument(
  37. "--print-duration", action=argparse.BooleanOptionalAction, default=True
  38. )
  39. parser.add_argument("--verbose", action="store_true")
  40. parser.add_argument("--offline", action="store_true")
  41. return parser.parse_args()
  42. def main() -> int:
  43. args = parse_args()
  44. print_duration = args.print_duration
  45. verbose_output = args.verbose
  46. offline = args.offline
  47. manager = LintManager(print_duration, verbose_output, offline)
  48. return_code = manager.run()
  49. return return_code
  50. def prefix(ci: str = "---") -> str:
  51. return ci + " " if buildkite.is_in_buildkite() else ""
  52. class LintManager:
  53. def __init__(self, print_duration: bool, verbose_output: bool, offline: bool):
  54. self.print_duration = print_duration
  55. self.verbose_output = verbose_output
  56. self.offline = offline
  57. def run(self) -> int:
  58. failed_checks = self.run_and_validate_if_no_previous_failures(
  59. CHECK_BEFORE_PATH, previous_failures=[]
  60. )
  61. failed_checks = self.run_and_validate_if_no_previous_failures(
  62. MAIN_CHECKS_PATH, previous_failures=failed_checks
  63. )
  64. failed_checks = self.run_and_validate_if_no_previous_failures(
  65. CHECK_AFTER_PATH, previous_failures=failed_checks
  66. )
  67. success = len(failed_checks) == 0
  68. print(
  69. prefix("+++") + f"{OK} All checks successful"
  70. if success
  71. else f"{FAIL} Checks failed: {failed_checks}"
  72. )
  73. return 0 if success else 1
  74. def is_ignore_file(self, path: Path) -> bool:
  75. return os.path.isdir(path)
  76. def run_and_validate_if_no_previous_failures(
  77. self, checks_path: Path, previous_failures: list[str]
  78. ) -> list[str]:
  79. if len(previous_failures) > 0:
  80. print(
  81. f"{prefix()}Skipping checks in '{checks_path}' due to previous failures"
  82. )
  83. return previous_failures
  84. else:
  85. return self.run_and_validate(checks_path)
  86. def run_and_validate(self, checks_path: Path) -> list[str]:
  87. """
  88. Runs checks in the given directory and validates their outcome.
  89. :return: names of failed checks
  90. """
  91. lint_files = [
  92. lint_file
  93. for lint_file in os.listdir(checks_path)
  94. if lint_file.endswith(".sh")
  95. and not self.is_ignore_file(checks_path / lint_file)
  96. ]
  97. lint_files.sort()
  98. threads = []
  99. check = "check" if len(lint_files) == 1 else "checks"
  100. status_printer_thread = StatusPrinterThread(
  101. f"{len(lint_files)} {check} in {checks_path.relative_to(MZ_ROOT)}"
  102. )
  103. status_printer_thread.start()
  104. for lint_file in lint_files:
  105. thread = LintingThread(checks_path, lint_file, offline=self.offline)
  106. thread.start()
  107. threads.append(thread)
  108. for thread in threads:
  109. thread.join()
  110. status_printer_thread.stop()
  111. failed_checks = []
  112. for thread in sorted(threads, key=lambda thread: thread.duration):
  113. formatted_duration = (
  114. f" [{thread.duration.total_seconds():5.2f}s]"
  115. if self.print_duration
  116. else ""
  117. )
  118. if thread.success:
  119. status = f"{OK}{formatted_duration}"
  120. print(f"{prefix('---')}{status} {thread.name}")
  121. else:
  122. status = f"{FAIL}{formatted_duration}"
  123. print(f"{prefix('+++')}{status} {thread.name}")
  124. failed_checks.append(thread.name)
  125. if thread.has_output() and (not thread.success or self.verbose_output):
  126. print(thread.output)
  127. return failed_checks
  128. class LintingThread(threading.Thread):
  129. def __init__(self, checks_path: Path, lint_file: str, offline: bool):
  130. super().__init__(target=self.run_single_script, args=(checks_path, lint_file))
  131. self.name = lint_file
  132. self.offline = offline
  133. self.output: str = ""
  134. self.success = False
  135. self.duration: timedelta | None = None
  136. def run_single_script(self, directory_path: Path, file_name: str) -> None:
  137. start_time = datetime.now()
  138. command = [str(directory_path / file_name)]
  139. if self.offline:
  140. command.append("--offline")
  141. try:
  142. # Note that coloring gets lost (e.g., in git diff)
  143. proc = subprocess.Popen(
  144. command,
  145. stdout=subprocess.PIPE,
  146. stderr=subprocess.STDOUT,
  147. )
  148. stdout, _ = proc.communicate()
  149. self.success = proc.returncode == 0
  150. self.capture_output(stdout)
  151. except Exception as e:
  152. print(f"Error: {e}")
  153. self.success = False
  154. end_time = datetime.now()
  155. self.duration = end_time - start_time
  156. def capture_output(self, stdout: bytes) -> None:
  157. # stdout contains both stdout and stderr because stderr is piped there
  158. self.output = stdout.decode("utf-8").strip()
  159. def has_output(self) -> bool:
  160. return len(self.output) > 0
  161. class StatusPrinterThread(threading.Thread):
  162. def __init__(self, current_step: str) -> None:
  163. super().__init__(target=self.print_status, args=())
  164. self.active = not buildkite.is_in_buildkite()
  165. self.current_step = current_step
  166. def print_status(self) -> None:
  167. symbols = ["⣾", "⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽"]
  168. i = 0
  169. while self.active:
  170. print(f"\r\033[K{symbols[i]} {self.current_step}", end="", flush=True)
  171. i = (i + 1) % len(symbols)
  172. time.sleep(0.1)
  173. def stop(self) -> None:
  174. if self.active:
  175. self.active = False
  176. print(f"\r\033[K{prefix()}{self.current_step}", end="", flush=True)
  177. print()
  178. if __name__ == "__main__":
  179. exit(main())