Coverage for src / competitive_verifier / verify / verifier.py: 98%

191 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-10-06 03:56 +0900

1import datetime 

2import hashlib 

3import pathlib 

4import time 

5from abc import ABC, abstractmethod 

6from collections.abc import Iterable 

7from functools import cached_property 

8from logging import getLogger 

9from typing import Literal 

10 

11from competitive_verifier import git, log 

12from competitive_verifier.download import download_files as run_download 

13from competitive_verifier.models import ( 

14 FileResult, 

15 ResultStatus, 

16 VerifcationTimeoutError, 

17 Verification, 

18 VerificationFile, 

19 VerificationInput, 

20 VerificationResult, 

21 VerifyCommandResult, 

22) 

23from competitive_verifier.resource import try_ulimit_stack 

24from competitive_verifier.verify.split_state import SplitState 

25 

26logger = getLogger(__name__) 

27 

28 

29def _now() -> datetime.datetime: 

30 return datetime.datetime.now(datetime.timezone.utc).astimezone() 

31 

32 

33def content_hash(files: Iterable[pathlib.Path]) -> str | None: 

34 """Digest of the paths and contents of ``files``, independent of their order. 

35 

36 ``None`` if any file can't be read. 

37 """ 

38 digest = hashlib.sha256() 

39 for file in sorted(files): 

40 try: 

41 content = file.read_bytes() 

42 except OSError: 

43 return None 

44 digest.update(file.as_posix().encode()) 

45 digest.update(b"\0") 

46 digest.update(content) 

47 digest.update(b"\0") 

48 return digest.hexdigest() 

49 

50 

51ChangeDetection = Literal["timestamp", "hash"] 

52 

53 

54class InputContainer(ABC): 

55 verifications: VerificationInput 

56 verification_time: datetime.datetime 

57 prev_result: VerifyCommandResult | None 

58 change_detection: ChangeDetection 

59 split_state: SplitState | None 

60 

61 def __init__( 

62 self, 

63 *, 

64 verifications: VerificationInput, 

65 verification_time: datetime.datetime, 

66 prev_result: VerifyCommandResult | None, 

67 split_state: SplitState | None, 

68 change_detection: ChangeDetection = "timestamp", 

69 ) -> None: 

70 self.verifications = verifications 

71 self.verification_time = verification_time 

72 self.prev_result = prev_result 

73 self.change_detection = change_detection 

74 self.split_state = split_state 

75 

76 @abstractmethod 

77 def get_file_timestamp(self, path: pathlib.Path) -> datetime.datetime: ... 

78 

79 def file_content_hash(self, path: pathlib.Path) -> str | None: 

80 """Digest of the file and its transitive dependencies. 

81 

82 ``None`` if any dependency is missing. 

83 """ 

84 if path not in self.verifications.files: 

85 return None 

86 return content_hash(self.verifications.transitive_depends_on[path]) 

87 

88 def file_testdata_hash(self, path: pathlib.Path) -> str | None: 

89 """Digest of the test data of the file's verifications. 

90 

91 ``None`` if the test data of any verification can't be identified. 

92 """ 

93 f = self.verifications.files.get(path) 

94 if f is None: 94 ↛ 95line 94 didn't jump to line 95 because the condition on line 94 was never true

95 return None 

96 digest = hashlib.sha256() 

97 for v in f.verification_list: 

98 testdata_hash = v.testdata_hash() 

99 if testdata_hash is None: 

100 return None 

101 digest.update(testdata_hash.encode()) 

102 digest.update(b"\0") 

103 return digest.hexdigest() 

104 

105 def file_need_verification( 

106 self, 

107 path: pathlib.Path, 

108 file_result: FileResult, 

109 ) -> bool: 

110 if not path.exists(): 

111 return False 

112 if self.change_detection == "timestamp": 

113 reason = file_result.need_verification( 

114 base_time=min(self.verification_time, self.get_file_timestamp(path)), 

115 testdata_hash=self.file_testdata_hash(path), 

116 ) 

117 elif self.change_detection == "hash": 117 ↛ 123line 117 didn't jump to line 123 because the condition on line 117 was always true

118 reason = file_result.need_verification( 

119 content_hash=self.file_content_hash(path), 

120 testdata_hash=self.file_testdata_hash(path), 

121 ) 

122 else: 

123 raise AssertionError(f"Unknown change_detection: {self.change_detection}") 

124 if reason: 

125 logger.info("%s needs verification: %s", path, reason.value) 

126 else: 

127 logger.info("%s doesn't need verification: %s", path, reason.value) 

128 return bool(reason) 

129 

130 @cached_property 

131 def verification_files(self) -> dict[pathlib.Path, VerificationFile]: 

132 """List of verification files.""" 

133 return { 

134 p: f for p, f in self.verifications.files.items() if f.is_verification() 

135 } 

136 

137 @cached_property 

138 def skippable_verification_files(self) -> dict[pathlib.Path, VerificationFile]: 

139 return { 

140 p: f 

141 for p, f in self.verification_files.items() 

142 if f.is_lightweight_verification() 

143 } 

144 

145 @cached_property 

146 def remaining_verification_files(self) -> dict[pathlib.Path, VerificationFile]: 

147 """List of verification files that have not yet been verified.""" 

148 verification_files = { 

149 p: f 

150 for p, f in self.verification_files.items() 

151 if p not in self.skippable_verification_files 

152 } 

153 

154 if self.prev_result is None: 

155 return verification_files 

156 

157 not_updated_files = { 

158 k 

159 for k, v in self.verifications.filterd_files(self.prev_result.files) 

160 if not self.file_need_verification(k, v) 

161 } 

162 return { 

163 p: f for p, f in verification_files.items() if p not in not_updated_files 

164 } 

165 

166 @cached_property 

167 def current_verification_files(self) -> dict[pathlib.Path, VerificationFile]: 

168 """List of verification files that self should verify. 

169 

170 if ``split_state`` is None the property is ``remaining_verification_files``; 

171 

172 else ``split_state.split(remaining_verification_files)``. 

173 """ 

174 if self.split_state is None: 

175 return self.remaining_verification_files 

176 

177 lst = [(p, f) for p, f in self.remaining_verification_files.items()] 

178 lst.sort(key=lambda tup: tup[0]) 

179 

180 return dict(self.split_state.split(lst)) 

181 

182 

183class BaseVerifier(InputContainer): 

184 timeout: float 

185 default_tle: float | None 

186 default_mle: float | None 

187 split_state: SplitState | None 

188 

189 _result: VerifyCommandResult | None 

190 

191 def __init__( 

192 self, 

193 verifications: VerificationInput, 

194 *, 

195 timeout: float, 

196 default_tle: float | None, 

197 default_mle: float | None, 

198 prev_result: VerifyCommandResult | None, 

199 split_state: SplitState | None, 

200 verification_time: datetime.datetime | None = None, 

201 change_detection: ChangeDetection = "timestamp", 

202 ) -> None: 

203 super().__init__( 

204 verifications=verifications, 

205 verification_time=verification_time or _now(), 

206 prev_result=prev_result, 

207 split_state=split_state, 

208 change_detection=change_detection, 

209 ) 

210 self._input = verifications 

211 self.timeout = timeout 

212 self.default_tle = default_tle 

213 self.default_mle = default_mle 

214 self._result = None 

215 

216 @property 

217 def is_first(self) -> bool: 

218 if not self.split_state: 

219 return True 

220 return self.split_state.index == 0 

221 

222 def _enumerate_verifications( 

223 self, 

224 p: pathlib.Path, 

225 f: VerificationFile, 

226 *, 

227 download: bool, 

228 deadline: float, 

229 ) -> list[VerificationResult]: 

230 logger.debug("%r", f) 

231 verifications = list[VerificationResult]() 

232 try: 

233 if time.perf_counter() > deadline: 

234 raise VerifcationTimeoutError # noqa: TRY301 

235 if download: 

236 run_download(f, check=True, group_log=False) 

237 else: 

238 for v in f.verification_list: 

239 if not v.is_testdata_cached(): 

240 raise RuntimeError( # noqa: TRY301 

241 f"The test data of {v} is not downloaded." 

242 " Run `competitive-verifier download`." 

243 ) 

244 except VerifcationTimeoutError: 

245 verifications.append( 

246 self.create_command_result(ResultStatus.SKIPPED, time.perf_counter()) 

247 ) 

248 logger.warning("Skip[Timeout]: %s", p) 

249 return verifications 

250 except BaseException: 

251 verifications.append( 

252 self.create_command_result(ResultStatus.FAILURE, time.perf_counter()) 

253 ) 

254 logger.exception( 

255 "Failed to download: %s", 

256 f.verification, 

257 extra={"github": log.GitHubMessageParams()}, 

258 ) 

259 return verifications 

260 

261 for ve in f.verification_list: 

262 logger.debug("command=%r", ve) 

263 prev_time = time.perf_counter() 

264 try: 

265 if prev_time > deadline: 

266 raise VerifcationTimeoutError # noqa: TRY301 

267 

268 rs, error_message = self.run_verification(ve, deadline=deadline) 

269 if error_message: 

270 logger.error( 

271 "%s: %s, verification=%s", 

272 error_message, 

273 p, 

274 ve.model_dump_json(exclude_unset=True), 

275 extra={"github": log.GitHubMessageParams(file=p)}, 

276 ) 

277 verifications.append( 

278 self.create_command_result(rs, prev_time, name=ve.name) 

279 ) 

280 except VerifcationTimeoutError: 

281 logger.warning("Skip[Timeout]: %s, %r", p, ve) 

282 verifications.append( 

283 self.create_command_result( 

284 ResultStatus.SKIPPED, 

285 prev_time, 

286 name=ve.name, 

287 ) 

288 ) 

289 except BaseException: 

290 logger.exception( 

291 "Failed to verify: %s, %r", 

292 p, 

293 ve, 

294 extra={"github": log.GitHubMessageParams()}, 

295 ) 

296 verifications.append( 

297 self.create_command_result( 

298 ResultStatus.FAILURE, 

299 prev_time, 

300 name=ve.name, 

301 ) 

302 ) 

303 return verifications 

304 

305 def verify(self, *, download: bool = True) -> VerifyCommandResult: 

306 start_time = time.perf_counter() 

307 deadline = start_time + self.timeout 

308 

309 if download: 

310 self.sync_testdata() 

311 

312 with log.group("current_verification_files"): 

313 current_verification_files = self.current_verification_files 

314 logger.info( 

315 "current_verification_files: %s", 

316 " ".join(p.as_posix() for p in current_verification_files), 

317 ) 

318 try_ulimit_stack() 

319 

320 file_results: dict[pathlib.Path, FileResult] = ( 

321 { 

322 k: v.model_copy(update={"newest": False}) 

323 for k, v in self.verifications.filterd_files(self.prev_result.files) 

324 if k.exists() 

325 } 

326 if self.prev_result 

327 else {} 

328 ) 

329 

330 for p, f in current_verification_files.items(): 

331 with log.group(f"Verify: {p.as_posix()}"): 

332 file_results[p] = FileResult( 

333 verifications=self._enumerate_verifications( 

334 p, 

335 f, 

336 download=download, 

337 deadline=deadline, 

338 ), 

339 content_hash=self.file_content_hash(p), 

340 testdata_hash=self.file_testdata_hash(p), 

341 ) 

342 

343 sippable_file_results = self.skippable_results() 

344 self._result = VerifyCommandResult( 

345 total_seconds=time.perf_counter() - start_time, 

346 files=file_results | sippable_file_results, 

347 ) 

348 return self._result 

349 

350 def run_verification( 

351 self, 

352 verification: Verification, 

353 *, 

354 deadline: float = float("inf"), 

355 ) -> tuple[ResultStatus | VerificationResult, str | None]: 

356 """Run verification. 

357 

358 Returns: 

359 tuple[ResultStatus, Optional[str]]: (Result, error_message) 

360 """ 

361 if not verification.run_compile_command(): 

362 return ResultStatus.FAILURE, "Failed to compile" 

363 

364 if time.perf_counter() > deadline: 

365 raise VerifcationTimeoutError 

366 

367 rs = verification.run(self, deadline=deadline) 

368 

369 if rs.status != ResultStatus.SUCCESS: 

370 return rs, "Failed to test" 

371 return rs, None 

372 

373 def sync_testdata(self) -> None: 

374 """Sync the test data sources so ``file_testdata_hash`` reflects upstream.""" 

375 for f in self.verification_files.values(): 

376 for v in f.verification_list: 

377 v.sync_testdata() 

378 

379 def skippable_results(self) -> dict[pathlib.Path, FileResult]: 

380 """Run skippable verification.""" 

381 results = dict[pathlib.Path, FileResult]() 

382 if self.is_first: 

383 for p, f in self.skippable_verification_files.items(): 

384 logger.info("Start skippable: %s", p) 

385 verifications = list[VerificationResult]() 

386 prev_time = time.perf_counter() 

387 

388 for v in f.verification_list: 

389 rs = self.run_verification(v)[0] 

390 verifications.append( 

391 self.create_command_result(rs, prev_time, name=v.name) 

392 ) 

393 results[p] = FileResult( 

394 verifications=verifications, 

395 content_hash=self.file_content_hash(p), 

396 testdata_hash=self.file_testdata_hash(p), 

397 newest=True, 

398 ) 

399 return results 

400 

401 def create_command_result( 

402 self, 

403 status_or_result: ResultStatus | VerificationResult, 

404 prev_time: float, 

405 *, 

406 name: str | None = None, 

407 ) -> VerificationResult: 

408 if isinstance(status_or_result, VerificationResult): 

409 return status_or_result 

410 

411 elapsed = time.perf_counter() - prev_time 

412 return VerificationResult( 

413 verification_name=name, 

414 status=status_or_result, 

415 elapsed=elapsed, 

416 last_execution_time=self.verification_time, 

417 ) 

418 

419 

420class Verifier(BaseVerifier): 

421 use_git_timestamp: bool 

422 

423 def __init__( 

424 self, 

425 verifications: VerificationInput, 

426 *, 

427 timeout: float, 

428 default_tle: float | None, 

429 default_mle: float | None, 

430 prev_result: VerifyCommandResult | None, 

431 split_state: SplitState | None, 

432 verification_time: datetime.datetime | None = None, 

433 change_detection: ChangeDetection = "timestamp", 

434 use_git_timestamp: bool, 

435 ) -> None: 

436 super().__init__( 

437 verifications=verifications, 

438 verification_time=verification_time or _now(), 

439 prev_result=prev_result, 

440 split_state=split_state, 

441 change_detection=change_detection, 

442 timeout=timeout, 

443 default_tle=default_tle, 

444 default_mle=default_mle, 

445 ) 

446 self.use_git_timestamp = use_git_timestamp 

447 

448 def get_file_timestamp(self, path: pathlib.Path) -> datetime.datetime: 

449 dependicies = self.verifications.transitive_depends_on[path] 

450 

451 if self.use_git_timestamp: 

452 return git.get_commit_time(dependicies) 

453 

454 timestamp = max(x.stat().st_mtime for x in dependicies) 

455 system_local_timezone = _now().tzinfo 

456 

457 # microsecond=0 is required because it's erased in git commit 

458 return datetime.datetime.fromtimestamp( 

459 timestamp, tz=system_local_timezone 

460 ).replace(microsecond=0)