Skip to content

Commit bf66b42

Browse files
committed
Add a script to check solutions.
1 parent e0e4f8b commit bf66b42

2 files changed

Lines changed: 179 additions & 0 deletions

File tree

cmscontrib/SolutionChecker.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
#!/usr/bin/env python3
2+
3+
# Contest Management System - http://cms-dev.github.io/
4+
# Copyright © 2026 Luca Versari <veluca93@gmail.com>
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU Affero General Public License as
8+
# published by the Free Software Foundation, either version 3 of the
9+
# License, or (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU Affero General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU Affero General Public License
17+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
18+
19+
"""Script to automate testing of solutions via CMS API.
20+
21+
The script expects a JSON file containing a list of solution checks.
22+
Each check should be an object with the following fields:
23+
- path: path to the solution file.
24+
- min_score: minimum expected score.
25+
- max_score: maximum expected score.
26+
27+
Such a file can be generated with `task-maker-rust export-solution-checks`.
28+
"""
29+
30+
import argparse
31+
import json
32+
import logging
33+
import os
34+
import sys
35+
import time
36+
from typing import Optional, Dict, Any
37+
38+
import requests
39+
40+
from cms.grading.languagemanager import filename_to_language
41+
42+
logger = logging.getLogger(__name__)
43+
44+
45+
class SolutionChecker:
46+
def __init__(self, base_url: str, username: str, password: Optional[str] = None):
47+
self.base_url = base_url.rstrip("/")
48+
self.username = username
49+
self.password = password
50+
self.session = requests.Session()
51+
self.auth_header = {}
52+
53+
def login(self):
54+
if self.password is None:
55+
logger.info("No password provided, assuming IP autologin.")
56+
return
57+
58+
login_url = f"{self.base_url}/api/login"
59+
response = self.session.post(
60+
login_url, data={"username": self.username, "password": self.password}
61+
)
62+
response.raise_for_status()
63+
data = response.json()
64+
self.auth_header = {"X-CMS-Authorization": data["login_data"]}
65+
logger.info("Successfully logged in.")
66+
67+
def submit(self, task_name: str, file_path: str) -> str:
68+
task_list_url = f"{self.base_url}/api/task_list"
69+
response = self.session.get(task_list_url, headers=self.auth_header)
70+
response.raise_for_status()
71+
tasks = response.json().get("tasks", [])
72+
submission_format = []
73+
for t in tasks:
74+
if t["name"] == task_name:
75+
submission_format = t.get("submission_format", [])
76+
break
77+
assert submission_format, f"Task {task_name} not found in task list"
78+
79+
submit_url = f"{self.base_url}/api/{task_name}/submit"
80+
filename = os.path.basename(file_path)
81+
files = {}
82+
for fmt in submission_format:
83+
files[fmt] = (filename, open(file_path, "rb"))
84+
85+
response = self.session.post(
86+
submit_url,
87+
files=files,
88+
data={"language": filename_to_language(file_path).name},
89+
headers=self.auth_header,
90+
)
91+
response.raise_for_status()
92+
return response.json().get("id")
93+
94+
def poll_status(self, task_name: str, submission_id: str) -> Dict[str, Any]:
95+
status_url = f"{self.base_url}/tasks/{task_name}/submissions/{submission_id}"
96+
while True:
97+
response = self.session.get(status_url, headers=self.auth_header)
98+
response.raise_for_status()
99+
data = response.json()
100+
# status 5 is SCORED, 2 is COMPILATION_FAILED
101+
if data.get("status") in [2, 5]:
102+
return data
103+
logger.info("Waiting for evaluation of %s...", submission_id)
104+
time.sleep(2)
105+
106+
107+
def main():
108+
parser = argparse.ArgumentParser(description="CMS Solution Checker")
109+
parser.add_argument(
110+
"--checks-json", "-c", required=True, help="Path to solution_checks.json"
111+
)
112+
parser.add_argument(
113+
"--url",
114+
"-u",
115+
required=True,
116+
help="CMS contest URL (e.g. http://localhost:8888/contest)",
117+
)
118+
parser.add_argument("--task", "-t", required=True, help="Task name")
119+
parser.add_argument("--username", "-U", required=True, help="CMS username")
120+
parser.add_argument("--password", "-p", help="CMS password")
121+
parser.add_argument(
122+
"--verbose", "-v", action="store_true", help="Enable verbose logging"
123+
)
124+
125+
args = parser.parse_args()
126+
127+
logging.basicConfig(
128+
level=logging.INFO if args.verbose else logging.WARNING,
129+
format="%(levelname)s: %(message)s",
130+
stream=sys.stdout,
131+
)
132+
133+
if not os.path.exists(args.checks_json):
134+
logger.error("%s not found.", args.checks_json)
135+
return 1
136+
137+
with open(args.checks_json, "r") as f:
138+
checks = json.load(f)
139+
140+
checker = SolutionChecker(args.url, args.username, args.password)
141+
checker.login()
142+
143+
submissions = {}
144+
logger.info("Submitting %d solutions...", len(checks))
145+
for criteria in checks:
146+
sol_path = criteria.get("path")
147+
sub_id = checker.submit(args.task, sol_path)
148+
submissions[sol_path] = (sub_id, criteria)
149+
logger.info("Submitted %s: %s", sol_path.split("/")[-1], sub_id)
150+
151+
has_failures = False
152+
logger.info("Waiting for evaluations...")
153+
for sol_path, (sub_id, criteria) in submissions.items():
154+
status = checker.poll_status(args.task, sub_id)
155+
failed = False
156+
sol_name = sol_path.split("/")[-1]
157+
if status:
158+
score = status.get("public_score")
159+
min_score = criteria.get("min_score")
160+
max_score = criteria.get("max_score")
161+
if score < min_score - 1e-7 or score > max_score + 1e-7:
162+
failed = True
163+
error = f"score {score} is not in range [{min_score}, {max_score}]"
164+
else:
165+
failed = True
166+
error = "Evaluation failed."
167+
168+
if not failed:
169+
logger.info("%20s: check successful", sol_name)
170+
else:
171+
has_failures = True
172+
logger.error("%20s: %s", sol_name, error)
173+
174+
return 1 if has_failures else 0
175+
176+
177+
if __name__ == "__main__":
178+
sys.exit(main())

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ class build_with_l10n(build):
145145
"cmsRemoveSubmissions=cmscontrib.RemoveSubmissions:main",
146146
"cmsRemoveTask=cmscontrib.RemoveTask:main",
147147
"cmsRemoveUser=cmscontrib.RemoveUser:main",
148+
"cmsSolutionChecker=cmscontrib.SolutionChecker:main",
148149
"cmsSpoolExporter=cmscontrib.SpoolExporter:main",
149150
"cmsMake=cmstaskenv.cmsMake:main",
150151
"cmsPrometheusExporter=cmscontrib.PrometheusExporter:main",

0 commit comments

Comments
 (0)