Coverage for src / competitive_verifier / models / result.py: 96%
111 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 enum
3import pathlib
4from logging import getLogger
5from typing import TYPE_CHECKING, Any
7from pydantic import BaseModel, Field, field_validator
9from competitive_verifier.log import GitHubMessageParams
10from competitive_verifier.util import to_relative
12from .path import ForcePosixPath
13from .result_status import JudgeStatus, ResultStatus
15if TYPE_CHECKING:
16 from _typeshed import StrPath
18logger = getLogger(__name__)
21class TestcaseResult(BaseModel):
22 name: str = Field(
23 description="The name of test case.",
24 )
25 """The name of test case.
26 """
28 status: JudgeStatus = Field(
29 description="The result status of the test case.",
30 )
31 """The result status of the test case.
32 """
34 elapsed: float = Field(
35 description="Number of seconds elapsed for the test case.",
36 )
37 """Number of seconds elapsed for the test case.
38 """
40 memory: float | None = Field(
41 default=None,
42 description="The size of memory used in megabytes.",
43 )
44 """The size of memory used in megabytes.
45 """
48class VerificationResult(BaseModel):
49 verification_name: str | None = Field(
50 default=None,
51 description="The name of verification.",
52 )
53 """The name of verification.
54 """
55 status: ResultStatus = Field(
56 description="The result status of verification.",
57 )
58 """The result status of verification.
59 """
61 elapsed: float = Field(
62 description="Total number of seconds elapsed for all test cases.",
63 )
64 """Total number of seconds elapsed for all test cases.
65 """
67 slowest: float | None = Field(
68 default=None,
69 description="Maximum number of seconds elapsed for each test cases.",
70 )
71 """Maximum number of seconds elapsed for each test cases.
72 """
74 heaviest: float | None = Field(
75 default=None,
76 description="Maximum size of memory used in megabytes.",
77 )
78 """Maximum size of memory used in megabytes.
79 """
81 testcases: list[TestcaseResult] | None = Field(
82 default=None,
83 description="The results of each test case.",
84 )
85 """The results of each test case.
86 """
88 last_execution_time: datetime.datetime = Field(
89 default_factory=lambda: datetime.datetime.now(datetime.timezone.utc),
90 description="The time at which the last validation was performed.",
91 )
92 """The time at which the last validation was performed.
93 """
95 @field_validator("status", mode="before")
96 @classmethod
97 def verification_list(cls, v: Any) -> Any: # noqa: ANN401
98 return v.lower() if isinstance(v, str) else v
100 def need_reverifying(self, base_time: datetime.datetime) -> bool:
101 if self.status != ResultStatus.SUCCESS:
102 return True
104 return self.last_execution_time < base_time
107class NeedVerification(enum.Enum):
108 """Why a previous ``FileResult`` can't be reused; falsy when it can."""
110 NO = "unchanged since the previous verification"
111 NO_RESULT = "no previous result"
112 NOT_SUCCESS = "the previous verification didn't succeed"
113 MODIFIED = "modified after the previous verification"
114 NO_CONTENT_HASH = "the previous result has no content hash"
115 CONTENT_CHANGED = "content hash changed"
117 def __bool__(self) -> bool:
118 return self is not NeedVerification.NO
121class FileResult(BaseModel):
122 verifications: list[VerificationResult] = Field(
123 default_factory=list[VerificationResult],
124 description="The results of each verification.",
125 )
126 """The results of each verification.
127 """
129 content_hash: str | None = Field(
130 default=None,
131 description="Digest of the file, its transitive dependencies,"
132 " and verification settings at verification time.",
133 )
134 """Digest of the sources and verification settings at verification time.
135 """
137 newest: bool = Field(
138 default=True,
139 description="Whether the verification was performed on the most recent run.",
140 )
141 """Whether the verification was performed on the most recent run.
142 """
144 def need_verification(
145 self,
146 base_time: datetime.datetime | None = None,
147 *,
148 content_hash: str | None = None,
149 ) -> bool:
150 """Whether the file needs verification.
152 Pass ``base_time`` to compare against the file's modification time,
153 or ``content_hash`` to compare against the recorded content hash.
154 """
155 return bool(
156 self.get_verification_reason(base_time=base_time, content_hash=content_hash)
157 )
159 def get_verification_reason(
160 self,
161 *,
162 base_time: datetime.datetime | None = None,
163 content_hash: str | None = None,
164 ) -> NeedVerification:
165 """Return why the file needs verification, or ``NeedVerification.NO``."""
166 if len(self.verifications) == 0:
167 return NeedVerification.NO_RESULT
168 if base_time is not None:
169 return self._need_verification_by_time(base_time)
170 return self._need_verification_by_hash(content_hash)
172 def _need_verification_by_time(
173 self, base_time: datetime.datetime
174 ) -> NeedVerification:
175 if any(r.status != ResultStatus.SUCCESS for r in self.verifications):
176 return NeedVerification.NOT_SUCCESS
177 if any(r.last_execution_time < base_time for r in self.verifications):
178 return NeedVerification.MODIFIED
179 return NeedVerification.NO
181 def _need_verification_by_hash(self, content_hash: str | None) -> NeedVerification:
182 if self.content_hash is None:
183 return NeedVerification.NO_CONTENT_HASH
184 if self.content_hash != content_hash:
185 return NeedVerification.CONTENT_CHANGED
186 if not self.is_success(allow_skip=False):
187 return NeedVerification.NOT_SUCCESS
188 return NeedVerification.NO
190 def is_success(self, *, allow_skip: bool) -> bool:
191 if allow_skip:
192 return all(r.status != ResultStatus.FAILURE for r in self.verifications)
193 return all(r.status == ResultStatus.SUCCESS for r in self.verifications)
196class VerifyCommandResult(BaseModel):
197 total_seconds: float = Field(
198 description="Total number of seconds elapsed for all verification.",
199 )
200 """Total number of seconds elapsed for all verification.
201 """
203 files: dict[ForcePosixPath, FileResult] = Field(
204 default_factory=dict[ForcePosixPath, FileResult],
205 description="The files to be verified.",
206 )
207 """The files to be verified.
208 """
210 @classmethod
211 def parse_file_relative(cls, path: "StrPath") -> "VerifyCommandResult":
212 impl = cls.model_validate_json(pathlib.Path(path).read_bytes())
213 new_files: dict[pathlib.Path, FileResult] = {}
214 for p, f in impl.files.items():
215 rp = to_relative(p)
216 if not rp:
217 logger.warning(
218 "Files in other directories are not subject to verification: %s",
219 p,
220 extra={"github": GitHubMessageParams()},
221 )
222 continue
223 new_files[rp] = f
225 impl.files = new_files
226 return impl
228 def merge(self, other: "VerifyCommandResult") -> "VerifyCommandResult":
229 d = self.files.copy()
230 for k, r in other.files.items():
231 cur = d.get(k)
232 if r.newest or (cur is None) or (not cur.newest):
233 d[k] = r
234 return VerifyCommandResult(
235 total_seconds=self.total_seconds + other.total_seconds,
236 files=d,
237 )
239 def is_success(self, *, allow_skip: bool = True) -> bool:
240 return all(f.is_success(allow_skip=allow_skip) for f in self.files.values())