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
« 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
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
26logger = getLogger(__name__)
29def _now() -> datetime.datetime:
30 return datetime.datetime.now(datetime.timezone.utc).astimezone()
33def content_hash(files: Iterable[pathlib.Path]) -> str | None:
34 """Digest of the paths and contents of ``files``, independent of their order.
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()
51ChangeDetection = Literal["timestamp", "hash"]
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
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
76 @abstractmethod
77 def get_file_timestamp(self, path: pathlib.Path) -> datetime.datetime: ...
79 def file_content_hash(self, path: pathlib.Path) -> str | None:
80 """Digest of the file and its transitive dependencies.
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])
88 def file_testdata_hash(self, path: pathlib.Path) -> str | None:
89 """Digest of the test data of the file's verifications.
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()
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)
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 }
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 }
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 }
152 if self.prev_result is None:
153 return verification_files
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 }
164 @cached_property
165 def current_verification_files(self) -> dict[pathlib.Path, VerificationFile]:
166 """List of verification files that self should verify.
168 if ``split_state`` is None the property is ``remaining_verification_files``;
170 else ``split_state.split(remaining_verification_files)``.
171 """
172 if self.split_state is None:
173 return self.remaining_verification_files
175 lst = [(p, f) for p, f in self.remaining_verification_files.items()]
176 lst.sort(key=lambda tup: tup[0])
178 return dict(self.split_state.split(lst))
181class BaseVerifier(InputContainer):
182 timeout: float
183 default_tle: float | None
184 default_mle: float | None
185 split_state: SplitState | None
187 _result: VerifyCommandResult | None
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
214 @property
215 def is_first(self) -> bool:
216 if not self.split_state:
217 return True
218 return self.split_state.index == 0
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
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
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
303 def verify(self, *, download: bool = True) -> VerifyCommandResult:
304 start_time = time.perf_counter()
305 deadline = start_time + self.timeout
307 if download and self.change_detection == "hash":
308 self.sync_testdata()
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()
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 )
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 )
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
348 def run_verification(
349 self,
350 verification: Verification,
351 *,
352 deadline: float = float("inf"),
353 ) -> tuple[ResultStatus | VerificationResult, str | None]:
354 """Run verification.
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"
362 if time.perf_counter() > deadline:
363 raise VerifcationTimeoutError
365 rs = verification.run(self, deadline=deadline)
367 if rs.status != ResultStatus.SUCCESS:
368 return rs, "Failed to test"
369 return rs, None
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()
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()
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
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
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 )
418class Verifier(BaseVerifier):
419 use_git_timestamp: bool
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
446 def get_file_timestamp(self, path: pathlib.Path) -> datetime.datetime:
447 dependicies = self.verifications.transitive_depends_on[path]
449 if self.use_git_timestamp:
450 return git.get_commit_time(dependicies)
452 timestamp = max(x.stat().st_mtime for x in dependicies)
453 system_local_timezone = _now().tzinfo
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)