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
« 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
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
27logger = getLogger(__name__)
30def _now() -> datetime.datetime:
31 return datetime.datetime.now(datetime.timezone.utc).astimezone()
34def content_hash(files: Iterable[pathlib.Path]) -> str | None:
35 """Digest of the paths and contents of ``files``, independent of their order.
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()
52ChangeDetection = Literal["timestamp", "hash"]
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
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
83 @abstractmethod
84 def get_file_timestamp(self, path: pathlib.Path) -> datetime.datetime: ...
86 def file_content_hash(self, path: pathlib.Path) -> str | None:
87 """Digest of the sources and verification settings for the file.
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()
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)
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 }
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 }
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 }
155 if self.prev_result is None:
156 return verification_files
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 }
167 @cached_property
168 def current_verification_files(self) -> dict[pathlib.Path, VerificationFile]:
169 """List of verification files that self should verify.
171 if ``split_state`` is None the property is ``remaining_verification_files``;
173 else ``split_state.split(remaining_verification_files)``.
174 """
175 if self.split_state is None:
176 return self.remaining_verification_files
178 lst = [(p, f) for p, f in self.remaining_verification_files.items()]
179 lst.sort(key=lambda tup: tup[0])
181 return dict(self.split_state.split(lst))
184class BaseVerifier(InputContainer):
185 timeout: float
186 split_state: SplitState | None
188 _result: VerifyCommandResult | None
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
215 @property
216 def is_first(self) -> bool:
217 if not self.split_state:
218 return True
219 return self.split_state.index == 0
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
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
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
304 def verify(self, *, download: bool = True) -> VerifyCommandResult:
305 start_time = time.perf_counter()
306 deadline = start_time + self.timeout
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()
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 )
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 )
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 VerificationTimeoutError
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 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()
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
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
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 )
416class Verifier(BaseVerifier):
417 use_git_timestamp: bool
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
444 def get_file_timestamp(self, path: pathlib.Path) -> datetime.datetime:
445 dependencies = self.verifications.transitive_depends_on[path]
447 if self.use_git_timestamp:
448 return git.get_commit_time(dependencies)
450 timestamp = max(x.stat().st_mtime for x in dependencies)
451 system_local_timezone = _now().tzinfo
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)