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

185 statements  

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

1import datetime 

2import hashlib 

3import json 

4import pathlib 

5import time 

6from abc import ABC, abstractmethod 

7from collections.abc import Iterable 

8from functools import cached_property 

9from logging import getLogger 

10from typing import Literal 

11 

12from competitive_verifier import git, log 

13from competitive_verifier.download import download_files as run_download 

14from competitive_verifier.models import ( 

15 FileResult, 

16 ResultStatus, 

17 Verification, 

18 VerificationFile, 

19 VerificationInput, 

20 VerificationResult, 

21 VerificationTimeoutError, 

22 VerifyCommandResult, 

23) 

24from competitive_verifier.resource import try_ulimit_stack 

25from competitive_verifier.verify.split_state import SplitState 

26 

27logger = getLogger(__name__) 

28 

29 

30def _now() -> datetime.datetime: 

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

32 

33 

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

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

36 

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

38 """ 

39 digest = hashlib.sha256() 

40 for file in sorted(files): 

41 try: 

42 content = file.read_bytes() 

43 except OSError: 

44 return None 

45 # Length prefixes preserve field boundaries even when content contains NUL. 

46 for field in (file.as_posix().encode(), content): 

47 digest.update(len(field).to_bytes(8, "big")) 

48 digest.update(field) 

49 return digest.hexdigest() 

50 

51 

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

53 

54 

55class InputContainer(ABC): 

56 verifications: VerificationInput 

57 verification_time: datetime.datetime 

58 prev_result: VerifyCommandResult | None 

59 change_detection: ChangeDetection 

60 split_state: SplitState | None 

61 default_tle: float | None 

62 default_mle: float | None 

63 

64 def __init__( 

65 self, 

66 *, 

67 verifications: VerificationInput, 

68 verification_time: datetime.datetime, 

69 prev_result: VerifyCommandResult | None, 

70 split_state: SplitState | None, 

71 change_detection: ChangeDetection = "timestamp", 

72 default_tle: float | None = None, 

73 default_mle: float | None = None, 

74 ) -> None: 

75 self.verifications = verifications 

76 self.verification_time = verification_time 

77 self.prev_result = prev_result 

78 self.change_detection = change_detection 

79 self.split_state = split_state 

80 self.default_tle = default_tle 

81 self.default_mle = default_mle 

82 

83 @abstractmethod 

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

85 

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

87 """Digest of the sources and verification settings for the file. 

88 

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

90 """ 

91 if path not in self.verifications.files: 

92 return None 

93 source_hash = content_hash(self.verifications.transitive_depends_on[path]) 

94 if source_hash is None: 

95 return None 

96 cache_inputs = { 

97 "source_hash": source_hash, 

98 "verifications": [ 

99 v.model_dump(mode="json") 

100 for v in self.verifications.files[path].verification_list 

101 ], 

102 "default_tle": self.default_tle, 

103 "default_mle": self.default_mle, 

104 } 

105 return hashlib.sha256( 

106 json.dumps(cache_inputs, sort_keys=True, separators=(",", ":")).encode() 

107 ).hexdigest() 

108 

109 def file_need_verification( 

110 self, 

111 path: pathlib.Path, 

112 file_result: FileResult, 

113 ) -> bool: 

114 if not path.exists(): 

115 return False 

116 if self.change_detection == "timestamp": 

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

118 reason = file_result.get_verification_reason(base_time=base_time) 

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

120 reason = file_result.get_verification_reason( 

121 content_hash=self.file_content_hash(path) 

122 ) 

123 else: 

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

125 if reason: 

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

127 else: 

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

129 return bool(reason) 

130 

131 @cached_property 

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

133 """List of verification files.""" 

134 return { 

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

136 } 

137 

138 @cached_property 

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

140 return { 

141 p: f 

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

143 if f.is_lightweight_verification() 

144 } 

145 

146 @cached_property 

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

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

149 verification_files = { 

150 p: f 

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

152 if p not in self.skippable_verification_files 

153 } 

154 

155 if self.prev_result is None: 

156 return verification_files 

157 

158 not_updated_files = { 

159 k 

160 for k, v in self.verifications.filtered_files(self.prev_result.files) 

161 if not self.file_need_verification(k, v) 

162 } 

163 return { 

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

165 } 

166 

167 @cached_property 

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

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

170 

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

172 

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

174 """ 

175 if self.split_state is None: 

176 return self.remaining_verification_files 

177 

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

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

180 

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

182 

183 

184class BaseVerifier(InputContainer): 

185 timeout: float 

186 split_state: SplitState | None 

187 

188 _result: VerifyCommandResult | None 

189 

190 def __init__( 

191 self, 

192 verifications: VerificationInput, 

193 *, 

194 timeout: float, 

195 default_tle: float | None, 

196 default_mle: float | None, 

197 prev_result: VerifyCommandResult | None, 

198 split_state: SplitState | None, 

199 verification_time: datetime.datetime | None = None, 

200 change_detection: ChangeDetection = "timestamp", 

201 ) -> None: 

202 super().__init__( 

203 verifications=verifications, 

204 verification_time=verification_time or _now(), 

205 prev_result=prev_result, 

206 split_state=split_state, 

207 change_detection=change_detection, 

208 default_tle=default_tle, 

209 default_mle=default_mle, 

210 ) 

211 self._input = verifications 

212 self.timeout = timeout 

213 self._result = None 

214 

215 @property 

216 def is_first(self) -> bool: 

217 if not self.split_state: 

218 return True 

219 return self.split_state.index == 0 

220 

221 def _enumerate_verifications( 

222 self, 

223 p: pathlib.Path, 

224 f: VerificationFile, 

225 *, 

226 download: bool, 

227 deadline: float, 

228 ) -> list[VerificationResult]: 

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

230 verifications = list[VerificationResult]() 

231 try: 

232 if time.perf_counter() > deadline: 

233 raise VerificationTimeoutError # noqa: TRY301 

234 if download: 

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

236 else: 

237 for v in f.verification_list: 

238 if not v.is_testdata_cached(): 

239 raise RuntimeError( # noqa: TRY301 

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

241 " Run `competitive-verifier download`." 

242 ) 

243 except VerificationTimeoutError: 

244 verifications.append( 

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

246 ) 

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

248 return verifications 

249 except BaseException: 

250 verifications.append( 

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

252 ) 

253 logger.exception( 

254 "Failed to download: %s", 

255 f.verification, 

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

257 ) 

258 return verifications 

259 

260 for ve in f.verification_list: 

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

262 prev_time = time.perf_counter() 

263 try: 

264 if prev_time > deadline: 

265 raise VerificationTimeoutError # noqa: TRY301 

266 

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

268 if error_message: 

269 logger.error( 

270 "%s: %s, verification=%s", 

271 error_message, 

272 p, 

273 ve.model_dump_json(exclude_unset=True), 

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

275 ) 

276 verifications.append( 

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

278 ) 

279 except VerificationTimeoutError: 

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

281 verifications.append( 

282 self.create_command_result( 

283 ResultStatus.SKIPPED, 

284 prev_time, 

285 name=ve.name, 

286 ) 

287 ) 

288 except BaseException: 

289 logger.exception( 

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

291 p, 

292 ve, 

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

294 ) 

295 verifications.append( 

296 self.create_command_result( 

297 ResultStatus.FAILURE, 

298 prev_time, 

299 name=ve.name, 

300 ) 

301 ) 

302 return verifications 

303 

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

305 start_time = time.perf_counter() 

306 deadline = start_time + self.timeout 

307 

308 with log.group("current_verification_files"): 

309 current_verification_files = self.current_verification_files 

310 logger.info( 

311 "current_verification_files: %s", 

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

313 ) 

314 try_ulimit_stack() 

315 

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

317 { 

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

319 for k, v in self.verifications.filtered_files(self.prev_result.files) 

320 if k.exists() 

321 } 

322 if self.prev_result 

323 else {} 

324 ) 

325 

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

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

328 file_hash = self.file_content_hash(p) 

329 verifications = self._enumerate_verifications( 

330 p, 

331 f, 

332 download=download, 

333 deadline=deadline, 

334 ) 

335 # Don't cache a result for sources that changed during verification. 

336 if file_hash != self.file_content_hash(p): 

337 file_hash = None 

338 file_results[p] = FileResult( 

339 verifications=verifications, 

340 content_hash=file_hash, 

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 VerificationTimeoutError 

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 skippable_results(self) -> dict[pathlib.Path, FileResult]: 

374 """Run skippable verification.""" 

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

376 if self.is_first: 

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

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

379 file_hash = self.file_content_hash(p) 

380 verifications = list[VerificationResult]() 

381 prev_time = time.perf_counter() 

382 

383 for v in f.verification_list: 

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

385 verifications.append( 

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

387 ) 

388 if file_hash != self.file_content_hash(p): 

389 file_hash = None 

390 results[p] = FileResult( 

391 verifications=verifications, 

392 content_hash=file_hash, 

393 newest=True, 

394 ) 

395 return results 

396 

397 def create_command_result( 

398 self, 

399 status_or_result: ResultStatus | VerificationResult, 

400 prev_time: float, 

401 *, 

402 name: str | None = None, 

403 ) -> VerificationResult: 

404 if isinstance(status_or_result, VerificationResult): 

405 return status_or_result 

406 

407 elapsed = time.perf_counter() - prev_time 

408 return VerificationResult( 

409 verification_name=name, 

410 status=status_or_result, 

411 elapsed=elapsed, 

412 last_execution_time=self.verification_time, 

413 ) 

414 

415 

416class Verifier(BaseVerifier): 

417 use_git_timestamp: bool 

418 

419 def __init__( 

420 self, 

421 verifications: VerificationInput, 

422 *, 

423 timeout: float, 

424 default_tle: float | None, 

425 default_mle: float | None, 

426 prev_result: VerifyCommandResult | None, 

427 split_state: SplitState | None, 

428 verification_time: datetime.datetime | None = None, 

429 change_detection: ChangeDetection = "timestamp", 

430 use_git_timestamp: bool, 

431 ) -> None: 

432 super().__init__( 

433 verifications=verifications, 

434 verification_time=verification_time or _now(), 

435 prev_result=prev_result, 

436 split_state=split_state, 

437 change_detection=change_detection, 

438 timeout=timeout, 

439 default_tle=default_tle, 

440 default_mle=default_mle, 

441 ) 

442 self.use_git_timestamp = use_git_timestamp 

443 

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

445 dependencies = self.verifications.transitive_depends_on[path] 

446 

447 if self.use_git_timestamp: 

448 return git.get_commit_time(dependencies) 

449 

450 timestamp = max(x.stat().st_mtime for x in dependencies) 

451 system_local_timezone = _now().tzinfo 

452 

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

454 return datetime.datetime.fromtimestamp( 

455 timestamp, tz=system_local_timezone 

456 ).replace(microsecond=0)