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

192 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-10-04 07:40 +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 base_time = min(self.verification_time, self.get_file_timestamp(path)) 

114 reason = file_result.need_verification(base_time=base_time) 

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

116 reason = file_result.need_verification( 

117 content_hash=self.file_content_hash(path), 

118 testdata_hash=self.file_testdata_hash(path), 

119 ) 

120 else: 

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

122 if reason: 

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

124 else: 

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

126 return bool(reason) 

127 

128 @cached_property 

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

130 """List of verification files.""" 

131 return { 

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

133 } 

134 

135 @cached_property 

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

137 return { 

138 p: f 

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

140 if f.is_lightweight_verification() 

141 } 

142 

143 @cached_property 

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

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

146 verification_files = { 

147 p: f 

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

149 if p not in self.skippable_verification_files 

150 } 

151 

152 if self.prev_result is None: 

153 return verification_files 

154 

155 not_updated_files = { 

156 k 

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

158 if not self.file_need_verification(k, v) 

159 } 

160 return { 

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

162 } 

163 

164 @cached_property 

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

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

167 

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

169 

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

171 """ 

172 if self.split_state is None: 

173 return self.remaining_verification_files 

174 

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

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

177 

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

179 

180 

181class BaseVerifier(InputContainer): 

182 timeout: float 

183 default_tle: float | None 

184 default_mle: float | None 

185 split_state: SplitState | None 

186 

187 _result: VerifyCommandResult | None 

188 

189 def __init__( 

190 self, 

191 verifications: VerificationInput, 

192 *, 

193 timeout: float, 

194 default_tle: float | None, 

195 default_mle: float | None, 

196 prev_result: VerifyCommandResult | None, 

197 split_state: SplitState | None, 

198 verification_time: datetime.datetime | None = None, 

199 change_detection: ChangeDetection = "timestamp", 

200 ) -> None: 

201 super().__init__( 

202 verifications=verifications, 

203 verification_time=verification_time or _now(), 

204 prev_result=prev_result, 

205 split_state=split_state, 

206 change_detection=change_detection, 

207 ) 

208 self._input = verifications 

209 self.timeout = timeout 

210 self.default_tle = default_tle 

211 self.default_mle = default_mle 

212 self._result = None 

213 

214 @property 

215 def is_first(self) -> bool: 

216 if not self.split_state: 

217 return True 

218 return self.split_state.index == 0 

219 

220 def _enumerate_verifications( 

221 self, 

222 p: pathlib.Path, 

223 f: VerificationFile, 

224 *, 

225 download: bool, 

226 deadline: float, 

227 ) -> list[VerificationResult]: 

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

229 verifications = list[VerificationResult]() 

230 try: 

231 if time.perf_counter() > deadline: 

232 raise VerifcationTimeoutError # noqa: TRY301 

233 if download: 

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

235 else: 

236 for v in f.verification_list: 

237 if not v.is_testdata_cached(): 

238 raise RuntimeError( # noqa: TRY301 

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

240 " Run `competitive-verifier download`." 

241 ) 

242 except VerifcationTimeoutError: 

243 verifications.append( 

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

245 ) 

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

247 return verifications 

248 except BaseException: 

249 verifications.append( 

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

251 ) 

252 logger.exception( 

253 "Failed to download: %s", 

254 f.verification, 

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

256 ) 

257 return verifications 

258 

259 for ve in f.verification_list: 

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

261 prev_time = time.perf_counter() 

262 try: 

263 if prev_time > deadline: 

264 raise VerifcationTimeoutError # noqa: TRY301 

265 

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

267 if error_message: 

268 logger.error( 

269 "%s: %s, verification=%s", 

270 error_message, 

271 p, 

272 ve.model_dump_json(exclude_unset=True), 

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

274 ) 

275 verifications.append( 

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

277 ) 

278 except VerifcationTimeoutError: 

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

280 verifications.append( 

281 self.create_command_result( 

282 ResultStatus.SKIPPED, 

283 prev_time, 

284 name=ve.name, 

285 ) 

286 ) 

287 except BaseException: 

288 logger.exception( 

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

290 p, 

291 ve, 

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

293 ) 

294 verifications.append( 

295 self.create_command_result( 

296 ResultStatus.FAILURE, 

297 prev_time, 

298 name=ve.name, 

299 ) 

300 ) 

301 return verifications 

302 

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

304 start_time = time.perf_counter() 

305 deadline = start_time + self.timeout 

306 

307 if download and self.change_detection == "hash": 

308 self.sync_testdata() 

309 

310 with log.group("current_verification_files"): 

311 current_verification_files = self.current_verification_files 

312 logger.info( 

313 "current_verification_files: %s", 

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

315 ) 

316 try_ulimit_stack() 

317 

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

319 { 

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

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

322 if k.exists() 

323 } 

324 if self.prev_result 

325 else {} 

326 ) 

327 

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

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

330 file_results[p] = FileResult( 

331 verifications=self._enumerate_verifications( 

332 p, 

333 f, 

334 download=download, 

335 deadline=deadline, 

336 ), 

337 content_hash=self.file_content_hash(p), 

338 testdata_hash=self.file_testdata_hash(p), 

339 ) 

340 

341 sippable_file_results = self.skippable_results() 

342 self._result = VerifyCommandResult( 

343 total_seconds=time.perf_counter() - start_time, 

344 files=file_results | sippable_file_results, 

345 ) 

346 return self._result 

347 

348 def run_verification( 

349 self, 

350 verification: Verification, 

351 *, 

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

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

354 """Run verification. 

355 

356 Returns: 

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

358 """ 

359 if not verification.run_compile_command(): 

360 return ResultStatus.FAILURE, "Failed to compile" 

361 

362 if time.perf_counter() > deadline: 

363 raise VerifcationTimeoutError 

364 

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

366 

367 if rs.status != ResultStatus.SUCCESS: 

368 return rs, "Failed to test" 

369 return rs, None 

370 

371 def sync_testdata(self) -> None: 

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

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

374 for v in f.verification_list: 

375 v.sync_testdata() 

376 

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

378 """Run skippable verification.""" 

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

380 if self.is_first: 

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

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

383 verifications = list[VerificationResult]() 

384 prev_time = time.perf_counter() 

385 

386 for v in f.verification_list: 

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

388 verifications.append( 

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

390 ) 

391 results[p] = FileResult( 

392 verifications=verifications, 

393 content_hash=self.file_content_hash(p), 

394 testdata_hash=self.file_testdata_hash(p), 

395 newest=True, 

396 ) 

397 return results 

398 

399 def create_command_result( 

400 self, 

401 status_or_result: ResultStatus | VerificationResult, 

402 prev_time: float, 

403 *, 

404 name: str | None = None, 

405 ) -> VerificationResult: 

406 if isinstance(status_or_result, VerificationResult): 

407 return status_or_result 

408 

409 elapsed = time.perf_counter() - prev_time 

410 return VerificationResult( 

411 verification_name=name, 

412 status=status_or_result, 

413 elapsed=elapsed, 

414 last_execution_time=self.verification_time, 

415 ) 

416 

417 

418class Verifier(BaseVerifier): 

419 use_git_timestamp: bool 

420 

421 def __init__( 

422 self, 

423 verifications: VerificationInput, 

424 *, 

425 timeout: float, 

426 default_tle: float | None, 

427 default_mle: float | None, 

428 prev_result: VerifyCommandResult | None, 

429 split_state: SplitState | None, 

430 verification_time: datetime.datetime | None = None, 

431 change_detection: ChangeDetection = "timestamp", 

432 use_git_timestamp: bool, 

433 ) -> None: 

434 super().__init__( 

435 verifications=verifications, 

436 verification_time=verification_time or _now(), 

437 prev_result=prev_result, 

438 split_state=split_state, 

439 change_detection=change_detection, 

440 timeout=timeout, 

441 default_tle=default_tle, 

442 default_mle=default_mle, 

443 ) 

444 self.use_git_timestamp = use_git_timestamp 

445 

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

447 dependicies = self.verifications.transitive_depends_on[path] 

448 

449 if self.use_git_timestamp: 

450 return git.get_commit_time(dependicies) 

451 

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

453 system_local_timezone = _now().tzinfo 

454 

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

456 return datetime.datetime.fromtimestamp( 

457 timestamp, tz=system_local_timezone 

458 ).replace(microsecond=0)