Skip to content

Commit 8c487f5

Browse files
committed
Refactor status texts + show help tooltips on some statuses
1 parent 0fe37a9 commit 8c487f5

File tree

15 files changed

+128
-93
lines changed

15 files changed

+128
-93
lines changed

cms/db/submission.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -326,8 +326,7 @@ class SubmissionResult(Base):
326326
nullable=True)
327327

328328
# The output from the sandbox (to allow localization the first item
329-
# of the list is a format string, possibly containing some "%s",
330-
# that will be filled in using the remaining items of the list).
329+
# of the list is a message ID, and the rest are format parameters).
331330
compilation_text: list[str] = Column(
332331
ARRAY(String),
333332
nullable=False,
@@ -758,9 +757,8 @@ class Evaluation(Base):
758757
nullable=True)
759758

760759
# The output from the grader, usually "Correct", "Time limit", ...
761-
# (to allow localization the first item of the list is a format
762-
# string, possibly containing some "%s", that will be filled in
763-
# using the remaining items of the list).
760+
# (to allow localization the first item of the list is a message ID, and
761+
# the rest are format parameters).
764762
text: list[str] = Column(
765763
ARRAY(String),
766764
nullable=False,

cms/db/usertest.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,8 +276,7 @@ class UserTestResult(Base):
276276
nullable=True)
277277

278278
# The output from the sandbox (to allow localization the first item
279-
# of the list is a format string, possibly containing some "%s",
280-
# that will be filled in using the remaining items of the list).
279+
# of the list is a message ID, and the rest are format parameters).
281280
compilation_text: list[str] = Column(
282281
ARRAY(String),
283282
nullable=False,

cms/grading/__init__.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525

2626
import logging
2727

28+
from markupsafe import Markup, escape
29+
30+
from cms.grading.steps.messages import MESSAGE_REGISTRY
2831
from cms.locale import DEFAULT_TRANSLATION, Translation
2932
from .language import Language, CompiledLanguage
3033

@@ -61,9 +64,10 @@ def format_status_text(
6164
6265
A status text is the content of SubmissionResult.compilation_text,
6366
Evaluation.text and UserTestResult.(compilation|evaluation)_text.
64-
It is a list whose first element is a string with printf-like
65-
placeholders and whose other elements are the data to use to fill
66-
them.
67+
It is a list whose first element is a message ID identifying a
68+
MessageCollection and a message in it, and whose other elements are the
69+
data to use to fill them. If the message ID begins with "custom:", it is
70+
used as a string directly without attempting to look up a message.
6771
The first element will be translated using the given translator (or
6872
the identity function, if not given), completed with the data and
6973
returned.
@@ -78,9 +82,21 @@ def format_status_text(
7882
if not isinstance(status, list):
7983
raise TypeError("Invalid type: %r" % type(status))
8084

81-
# The empty msgid corresponds to the headers of the pofile.
82-
text = _(status[0]) if status[0] != '' else ''
83-
return text % tuple(status[1:])
85+
if status[0] == '':
86+
return ''
87+
elif status[0].startswith("custom:"):
88+
return status[0].removeprefix("custom:") % tuple(status[1:])
89+
else:
90+
message = MESSAGE_REGISTRY.get(status[0])
91+
msg_text = _(message.message) % tuple(status[1:])
92+
if message.inline_help:
93+
# XXX: is this the best place for this?
94+
help = Markup(
95+
f' <i class="icon-question-sign" title="{escape(message.help_text)}"></i>'
96+
)
97+
return msg_text + help
98+
else:
99+
return msg_text
84100
except Exception:
85101
logger.error("Unexpected error when formatting status "
86102
"text: %r", status, exc_info=True)

cms/grading/scoretypes/abc.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
3737

3838
from cms import FEEDBACK_LEVEL_RESTRICTED
3939
from cms.db import SubmissionResult
40-
from cms.grading.steps import EVALUATION_MESSAGES
4140
from cms.locale import Translation, DEFAULT_TRANSLATION
4241
from cms.server.jinja2_toolbox import GLOBAL_ENVIRONMENT
4342
from jinja2 import Template
@@ -469,7 +468,7 @@ def compute_score(self, submission_result):
469468
tc_score, parameter)
470469

471470
time_limit_was_exceeded = False
472-
if evaluations[tc_idx].text == [EVALUATION_MESSAGES.get("timeout").message]:
471+
if evaluations[tc_idx].text == ["evaluation:timeout"]:
473472
time_limit_was_exceeded = True
474473

475474
testcases.append({

cms/grading/steps/compilation.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def N_(message: str):
4242
return message
4343

4444

45-
COMPILATION_MESSAGES = MessageCollection([
45+
COMPILATION_MESSAGES = MessageCollection("compilation", [
4646
HumanMessage("success",
4747
N_("Compilation succeeded"),
4848
N_("Your submission successfully compiled to an "
@@ -54,17 +54,20 @@ def N_(message: str):
5454
N_("Compilation timed out"),
5555
N_("Your submission exceeded the time limit while compiling. "
5656
"This might be caused by an excessive use of C++ "
57-
"templates, for example.")),
57+
"templates, for example."),
58+
inline_help=True),
5859
HumanMessage("memorylimit",
5960
N_("Compilation memory limit exceeded"),
6061
N_("Your submission exceeded the memory limit while compiling. "
6162
"This might be caused by an excessive use of C++ "
62-
"templates, or too large global variables, for example.")),
63+
"templates, or too large global variables, for example."),
64+
inline_help=True),
6365
HumanMessage("signal",
6466
N_("Compilation killed with signal %s"),
6567
N_("Your submission was killed with the specified signal. "
6668
"This might be caused by a bug in the compiler, "
67-
"for example.")),
69+
"for example."),
70+
inline_help=True),
6871
])
6972

7073

@@ -89,8 +92,9 @@ def compilation_step(
8992
executable, False if not, None if success is False;
9093
* text: a human readable, localized message to inform contestants
9194
of the status; it is either an empty list (for no message) or a
92-
list of strings were the second to the last are formatting
93-
arguments for the first, or None if success is False;
95+
list of strings were the first is a message ID and the rest are
96+
format arguments for that message, if the message takes any; or
97+
None if success is False;
9498
* stats: a dictionary with statistics about the compilation, or None
9599
if success is False.
96100
@@ -121,37 +125,37 @@ def compilation_step(
121125
if exit_status == Sandbox.EXIT_OK:
122126
# Execution finished successfully and the executable was generated.
123127
logger.debug("Compilation successfully finished.")
124-
text = [COMPILATION_MESSAGES.get("success").message]
128+
text = ["compilation:success"]
125129
return True, True, text, stats
126130

127131
elif exit_status == Sandbox.EXIT_NONZERO_RETURN:
128132
# Error in compilation: no executable was generated, and we return
129133
# an error to the user.
130134
logger.debug("Compilation failed.")
131-
text = [COMPILATION_MESSAGES.get("fail").message]
135+
text = ["compilation:fail"]
132136
return True, False, text, stats
133137

134138
elif exit_status == Sandbox.EXIT_TIMEOUT or \
135139
exit_status == Sandbox.EXIT_TIMEOUT_WALL:
136140
# Timeout: we assume it is the user's fault, and we return the error
137141
# to them.
138142
logger.debug("Compilation timed out.")
139-
text = [COMPILATION_MESSAGES.get("timeout").message]
143+
text = ["compilation:timeout"]
140144
return True, False, text, stats
141145

142146
elif exit_status == Sandbox.EXIT_MEM_LIMIT:
143147
# Memory limit: we assume it is the user's fault, and we return the
144148
# error to them.
145149
logger.debug("Compilation memory limit exceeded.")
146-
text = [COMPILATION_MESSAGES.get("memorylimit").message]
150+
text = ["compilation:memorylimit"]
147151
return True, False, text, stats
148152

149153
elif exit_status == Sandbox.EXIT_SIGNAL:
150154
# Terminated by signal: we assume again it is the user's fault, and
151155
# we return the error to them.
152156
signal = stats["signal"]
153157
logger.debug("Compilation killed with signal %s.", signal)
154-
text = [COMPILATION_MESSAGES.get("signal").message, str(signal)]
158+
text = ["compilation:signal", str(signal)]
155159
return True, False, text, stats
156160

157161
elif exit_status == Sandbox.EXIT_SANDBOX_ERROR:

cms/grading/steps/evaluation.py

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def N_(message: str):
4242
return message
4343

4444

45-
EVALUATION_MESSAGES = MessageCollection([
45+
EVALUATION_MESSAGES = MessageCollection("evaluation", [
4646
HumanMessage("success",
4747
N_("Output is correct"),
4848
N_("Your submission ran and gave the correct answer")),
@@ -56,27 +56,38 @@ def N_(message: str):
5656
HumanMessage("nooutput",
5757
N_("Evaluation didn't produce file %s"),
5858
N_("Your submission ran, but did not write on the "
59-
"correct output file")),
59+
"correct output file"),
60+
inline_help=True),
6061
HumanMessage("timeout",
6162
N_("Execution timed out"),
62-
N_("Your submission used too much CPU time.")),
63+
N_("Your submission used too much CPU time."),
64+
inline_help=True),
6365
HumanMessage("walltimeout",
6466
N_("Execution timed out (wall clock limit exceeded)"),
6567
N_("Your submission used too much total time. This might "
6668
"be triggered by undefined code, or buffer overflow, "
6769
"for example. Note that in this case the CPU time "
6870
"visible in the submission details might be much smaller "
69-
"than the time limit.")),
71+
"than the time limit."),
72+
inline_help=True),
7073
HumanMessage("memorylimit",
7174
N_("Memory limit exceeded"),
72-
N_("Your submission used too much memory.")),
75+
N_("Your submission used too much memory."),
76+
inline_help=True),
7377
HumanMessage("signal",
7478
N_("Execution killed by signal"),
75-
N_("The evaluation was killed by a signal.")),
79+
N_("The evaluation was killed by a signal."),
80+
inline_help=True),
7681
HumanMessage("returncode",
7782
N_("Execution failed because the return code was nonzero"),
7883
N_("Your submission failed because it exited with a return "
79-
"code different from 0.")),
84+
"code different from 0."),
85+
inline_help=True),
86+
])
87+
# This message is stored separately because we don't want to show it on the help page.
88+
EXECUTION_MESSAGES = MessageCollection("execution", [
89+
HumanMessage("success", N_("Execution completed successfully"), ""),
90+
# all other user test messages are shared with the regular evaluation messages.
8091
])
8192

8293

@@ -270,27 +281,26 @@ def human_evaluation_message(stats: StatsDict) -> list[str]:
270281
271282
stats: execution statistics for an evaluation step.
272283
273-
return: a list of strings composing the message (where
274-
strings from the second to the last are formatting arguments for the
275-
first); or an empty list if no message should be passed to
276-
contestants.
284+
return: a list of strings composing the message (where the first is a message
285+
ID and the rest are format arguments for the message); or an empty list
286+
if no message should be passed to contestants.
277287
278288
"""
279289
exit_status = stats['exit_status']
280290
if exit_status == Sandbox.EXIT_TIMEOUT:
281-
return [EVALUATION_MESSAGES.get("timeout").message]
291+
return ["evaluation:timeout"]
282292
elif exit_status == Sandbox.EXIT_TIMEOUT_WALL:
283-
return [EVALUATION_MESSAGES.get("walltimeout").message]
293+
return ["evaluation:walltimeout"]
284294
elif exit_status == Sandbox.EXIT_SIGNAL:
285-
return [EVALUATION_MESSAGES.get("signal").message]
295+
return ["evaluation:signal"]
286296
elif exit_status == Sandbox.EXIT_SANDBOX_ERROR:
287297
# Contestants won't see this, the submission will still be evaluating.
288298
return []
289299
elif exit_status == Sandbox.EXIT_MEM_LIMIT:
290-
return [EVALUATION_MESSAGES.get("memorylimit").message]
300+
return ["evaluation:memorylimit"]
291301
elif exit_status == Sandbox.EXIT_NONZERO_RETURN:
292302
# Don't tell which code: would be too much information!
293-
return [EVALUATION_MESSAGES.get("returncode").message]
303+
return ["evaluation:returncode"]
294304
elif exit_status == Sandbox.EXIT_OK:
295305
return []
296306
else:

cms/grading/steps/messages.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,28 +37,31 @@ class HumanMessage:
3737
3838
"""
3939

40-
def __init__(self, shorthand: str, message: str, help_text: str):
40+
def __init__(self, shorthand: str, message: str, help_text: str, inline_help: bool = False):
4141
"""Initialization.
4242
4343
shorthand: what to call this message in the code.
4444
message: the message itself.
4545
help_text: a longer explanation for the help page.
46+
inline_help: Whether to show a help tooltip for this message whenever it is shown.
4647
4748
"""
4849
self.shorthand = shorthand
4950
self.message = message
5051
self.help_text = help_text
52+
self.inline_help = inline_help
5153

5254

5355
class MessageCollection:
5456
"""Represent a collection of messages, with error checking."""
5557

56-
def __init__(self, messages: list[HumanMessage] | None = None):
58+
def __init__(self, namespace: str, messages: list[HumanMessage] | None = None):
5759
self._messages: dict[str, HumanMessage] = {}
5860
self._ordering: list[str] = []
5961
if messages is not None:
6062
for message in messages:
6163
self.add(message)
64+
MESSAGE_REGISTRY.add(namespace, self)
6265

6366
def add(self, message: HumanMessage):
6467
if message.shorthand in self._messages:
@@ -81,3 +84,28 @@ def all(self) -> list[HumanMessage]:
8184
for shorthand in self._ordering:
8285
ret.append(self._messages[shorthand])
8386
return ret
87+
88+
class MessageRegistry:
89+
"""Represents a collection of message collections, organized by a namespace
90+
prefix. This is a singleton that is automatically populated by
91+
MessageCollection."""
92+
93+
def __init__(self):
94+
self._namespaces: dict[str, MessageCollection] = {}
95+
96+
def add(self, namespace: str, collection: MessageCollection):
97+
if namespace in self._namespaces:
98+
logger.error(f"Trying to register duplicate namespace {namespace}")
99+
return
100+
self._namespaces[namespace] = collection
101+
102+
def get(self, message_id: str) -> HumanMessage:
103+
if ':' not in message_id:
104+
raise KeyError(f"Invalid message ID {message_id}")
105+
namespace, message = message_id.split(':', 1)
106+
if namespace not in self._namespaces:
107+
raise KeyError(f"Message namespace {namespace} not found")
108+
collection = self._namespaces[namespace]
109+
return collection.get(message)
110+
111+
MESSAGE_REGISTRY = MessageRegistry()

cms/grading/steps/trusted.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@
4141

4242
from cms import config
4343
from cms.grading.Sandbox import Sandbox
44-
from .evaluation import EVALUATION_MESSAGES
4544
from .utils import generic_step
4645
from .stats import StatsDict
4746

@@ -136,13 +135,15 @@ def extract_outcome_and_text(sandbox: Sandbox) -> tuple[float, list[str], str |
136135
# If the text starts with translate, the manager is asking us to
137136
# use a stock message, that can be translated.
138137
if text.startswith("translate:"):
139-
remaining = text[len("translate:"):].strip()
138+
remaining = text.removeprefix("translate:").strip()
140139
if remaining in ["success", "partial", "wrong"]:
141-
text = EVALUATION_MESSAGES.get(remaining).message
140+
text = "evaluation:" + remaining
142141
else:
143142
remaining = remaining[:15] # to avoid logging lots of text
144143
logger.warning("Manager asked to translate text, but string "
145144
"'%s' is not recognized." % remaining)
145+
else:
146+
text = "custom:" + text
146147

147148
return outcome, [text], admin_text
148149

cms/grading/steps/whitediff.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,6 @@
3030

3131
from cms.grading.Sandbox import Sandbox
3232

33-
from .evaluation import EVALUATION_MESSAGES
34-
3533

3634
logger = logging.getLogger(__name__)
3735

@@ -143,9 +141,9 @@ def white_diff_fobj_step(
143141
"""
144142
correct, admin_text = _white_diff(output_fobj, correct_output_fobj)
145143
if correct:
146-
return 1.0, [EVALUATION_MESSAGES.get("success").message], admin_text
144+
return 1.0, ["evaluation:success"], admin_text
147145
else:
148-
return 0.0, [EVALUATION_MESSAGES.get("wrong").message], admin_text
146+
return 0.0, ["evaluation:wrong"], admin_text
149147

150148

151149
def white_diff_step(
@@ -169,5 +167,4 @@ def white_diff_step(
169167
sandbox.get_file(correct_output_filename) as res_file:
170168
return white_diff_fobj_step(out_file, res_file)
171169
else:
172-
return 0.0, [
173-
EVALUATION_MESSAGES.get("nooutput").message, output_filename], None
170+
return 0.0, ["evaluation:nooutput", output_filename], None

0 commit comments

Comments
 (0)