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
« 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
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 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)
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 }
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 }
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 }
154 if self.prev_result is None:
155 return verification_files
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 }
166 @cached_property
167 def current_verification_files(self) -> dict[pathlib.Path, VerificationFile]:
168 """List of verification files that self should verify.
170 if ``split_state`` is None the property is ``remaining_verification_files``;
172 else ``split_state.split(remaining_verification_files)``.
173 """
174 if self.split_state is None:
175 return self.remaining_verification_files
177 lst = [(p, f) for p, f in self.remaining_verification_files.items()]
178 lst.sort(key=lambda tup: tup[0])
180 return dict(self.split_state.split(lst))
183class BaseVerifier(InputContainer):
184 timeout: float
185 default_tle: float | None
186 default_mle: float | None
187 split_state: SplitState | None
189 _result: VerifyCommandResult | None
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
216 @property
217 def is_first(self) -> bool:
218 if not self.split_state:
219 return True
220 return self.split_state.index == 0
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
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
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
305 def verify(self, *, download: bool = True) -> VerifyCommandResult:
306 start_time = time.perf_counter()
307 deadline = start_time + self.timeout
309 if download:
310 self.sync_testdata()
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()
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 )
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 )
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
350 def run_verification(
351 self,
352 verification: Verification,
353 *,
354 deadline: float = float("inf"),
355 ) -> tuple[ResultStatus | VerificationResult, str | None]:
356 """Run verification.
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"
364 if time.perf_counter() > deadline:
365 raise VerifcationTimeoutError
367 rs = verification.run(self, deadline=deadline)
369 if rs.status != ResultStatus.SUCCESS:
370 return rs, "Failed to test"
371 return rs, None
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()
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()
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
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
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 )
420class Verifier(BaseVerifier):
421 use_git_timestamp: bool
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
448 def get_file_timestamp(self, path: pathlib.Path) -> datetime.datetime:
449 dependicies = self.verifications.transitive_depends_on[path]
451 if self.use_git_timestamp:
452 return git.get_commit_time(dependicies)
454 timestamp = max(x.stat().st_mtime for x in dependicies)
455 system_local_timezone = _now().tzinfo
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)