Skip to content

Commit 68ed31b

Browse files
Feat/custom cell timeout (#321)
Custom cell timeout feature allows users to set fixed cell timeout per assignment. * WIP: add cell_timeout to assignment settings * WIP: create function to validate user custom cell timeout against config cell timeout * WIP: initial API endpoint for config * WIP: set default cell timeout in config file * test: write API test for api/config endpoint * fix: rewrite tests to adhere to new implementation of timeout_func * test: test setting default_cell_timeout to an invalid value * fix: change timeout_func trait type to Int * fix: validate config values of default, min and max cell timeout * config: set default_cell_timeoutin grader_service_config.py for docker * fix: pass the result of _determine_cell_timeout correctly
1 parent 3180497 commit 68ed31b

File tree

10 files changed

+163
-29
lines changed

10 files changed

+163
-29
lines changed

api-tests/api/get-config.bru

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
meta {
2+
name: get-config
3+
type: http
4+
seq: 2
5+
}
6+
7+
get {
8+
url: {{grader-base-url}}/api/config
9+
body: none
10+
auth: inherit
11+
}
12+
13+
settings {
14+
encodeUrl: true
15+
timeout: 0
16+
}

docs/source/_static/openapi/schemas.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ components:
9292
group:
9393
type: "string"
9494
example: "Chapter 1: Data Types"
95+
cell_timeout:
96+
type: "integer"
97+
example: "300"
9598
example:
9699
late_submission:
97100
- period: "P1W1D"

examples/dev_environment/grader_service_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
c.GraderService.log_level = "DEBUG"
2424

2525
c.RequestHandlerConfig.autograde_executor_class = LocalAutogradeExecutor
26+
c.LocalAutogradeExecutor.default_cell_timeout = 200
2627

2728
c.CeleryApp.conf = dict(
2829
broker_url="amqp://localhost",

examples/docker_compose/grader_service_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
c.GraderService.db_url = db_url
3434

3535
c.RequestHandlerConfig.autograde_executor_class = LocalAutogradeExecutor
36+
c.LocalAutogradeExecutor.default_cell_timeout = 200
3637

3738
# get rabbitmq username and password
3839
rabbit_mq_username = os.getenv("RABBITMQ_GRADER_SERVICE_USERNAME")

grader_service/api/models/assignment_settings.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class AssignmentSettings(Model):
1414
Do not edit the class manually.
1515
"""
1616

17-
def __init__(self, deadline=None, max_submissions=None, allowed_files=[], late_submission=None, autograde_type='auto', group=None): # noqa: E501
17+
def __init__(self, deadline=None, max_submissions=None, allowed_files=[], late_submission=None, autograde_type='auto', group=None, cell_timeout=None): # noqa: E501
1818
"""AssignmentSettings - a model defined in OpenAPI
1919
2020
:param deadline: The deadline of this AssignmentSettings. # noqa: E501
@@ -29,14 +29,17 @@ def __init__(self, deadline=None, max_submissions=None, allowed_files=[], late_s
2929
:type autograde_type: str
3030
:param group: The group of this AssignmentSettings. # noqa: E501
3131
:type group: str
32+
:param cell_timeout: The cell_timeout of this AssignmentSettings. # noqa: E501
33+
:type cell_timeout: int
3234
"""
3335
self.openapi_types = {
3436
'deadline': datetime,
3537
'max_submissions': int,
3638
'allowed_files': List[str],
3739
'late_submission': List[SubmissionPeriod],
3840
'autograde_type': str,
39-
'group': str
41+
'group': str,
42+
'cell_timeout': int
4043
}
4144

4245
self.attribute_map = {
@@ -45,7 +48,8 @@ def __init__(self, deadline=None, max_submissions=None, allowed_files=[], late_s
4548
'allowed_files': 'allowed_files',
4649
'late_submission': 'late_submission',
4750
'autograde_type': 'autograde_type',
48-
'group': 'group'
51+
'group': 'group',
52+
'cell_timeout': 'cell_timeout'
4953
}
5054

5155
self._deadline = deadline
@@ -54,6 +58,7 @@ def __init__(self, deadline=None, max_submissions=None, allowed_files=[], late_s
5458
self._late_submission = late_submission
5559
self._autograde_type = autograde_type
5660
self._group = group
61+
self._cell_timeout = cell_timeout
5762

5863
@classmethod
5964
def from_dict(cls, dikt) -> 'AssignmentSettings':
@@ -197,3 +202,24 @@ def group(self, group: str):
197202
"""
198203

199204
self._group = group
205+
206+
@property
207+
def cell_timeout(self) -> int:
208+
"""Gets the cell_timeout of this AssignmentSettings.
209+
210+
211+
:return: The cell_timeout of this AssignmentSettings.
212+
:rtype: int
213+
"""
214+
return self._cell_timeout
215+
216+
@cell_timeout.setter
217+
def cell_timeout(self, cell_timeout: int):
218+
"""Sets the cell_timeout of this AssignmentSettings.
219+
220+
221+
:param cell_timeout: The cell_timeout of this AssignmentSettings.
222+
:type cell_timeout: int
223+
"""
224+
225+
self._cell_timeout = cell_timeout

grader_service/autograding/kube/kube_grader.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ def _start_pod(self) -> GraderPod:
350350
"-p",
351351
"*.ipynb",
352352
"--log-level=INFO",
353-
f"--ExecutePreprocessor.timeout={self.timeout_func(self.assignment.lecture)}",
353+
f"--ExecutePreprocessor.timeout={self.cell_timeout}",
354354
]
355355
volumes = [self.volume] + self.extra_volumes
356356
volume_mounts = [

grader_service/autograding/local_grader.py

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,18 @@
1717
from sqlalchemy.orm import Session
1818
from traitlets.config import Config
1919
from traitlets.config.configurable import LoggingConfigurable
20-
from traitlets.traitlets import Callable, TraitError, Type, Unicode, validate
20+
from traitlets.traitlets import Int, TraitError, Type, Unicode, validate
2121

2222
from grader_service.autograding.git_manager import GitSubmissionManager
2323
from grader_service.autograding.utils import collect_logs, executable_validator, rmtree
2424
from grader_service.convert.converters.autograde import Autograde
2525
from grader_service.convert.gradebook.models import GradeBookModel
2626
from grader_service.orm.assignment import Assignment
27-
from grader_service.orm.lecture import Lecture
2827
from grader_service.orm.submission import AutoStatus, ManualStatus, Submission
2928
from grader_service.orm.submission_logs import SubmissionLogs
3029
from grader_service.orm.submission_properties import SubmissionProperties
3130

3231

33-
def default_timeout_func(lecture: Lecture) -> int:
34-
return 360
35-
36-
3732
class LocalAutogradeExecutor(LoggingConfigurable):
3833
"""
3934
Runs an autograde job on the local machine
@@ -46,10 +41,19 @@ class LocalAutogradeExecutor(LoggingConfigurable):
4641
relative_output_path = Unicode("convert_out", allow_none=True).tag(config=True)
4742
git_manager_class = Type(GitSubmissionManager, allow_none=False).tag(config=True)
4843

49-
timeout_func = Callable(
50-
default_timeout_func,
44+
cell_timeout = Int(
5145
allow_none=False,
52-
help="Function that takes a lecture as an argument and returns the cell timeout in seconds.",
46+
help="Returns the cell timeout in seconds, either user-defined, from configuration or default values.",
47+
).tag(config=True)
48+
49+
default_cell_timeout = Int(300, help="Default cell timeout in seconds, defaults to 300").tag(
50+
config=True
51+
)
52+
53+
min_cell_timeout = Int(10, help="Min cell timeout in seconds, defaults to 10.").tag(config=True)
54+
55+
max_cell_timeout = Int(
56+
86400, help="Max cell timeout in seconds, defaults to 86400 (24 hours)"
5357
).tag(config=True)
5458

5559
def __init__(
@@ -86,6 +90,8 @@ def __init__(
8690
# Git manager performs the git operations when creating a new repo for the grading results
8791
self.git_manager = self.git_manager_class(grader_service_dir, self.submission)
8892

93+
self.cell_timeout = self._determine_cell_timeout()
94+
8995
def start(self):
9096
"""
9197
Starts the autograding job.
@@ -219,7 +225,7 @@ def _put_grades_in_assignment_properties(self) -> str:
219225
def _get_autograde_config(self) -> Config:
220226
"""Returns the autograde config, with the timeout set for ExecutePreprocessor."""
221227
c = Config()
222-
c.ExecutePreprocessor.timeout = self.timeout_func(self.assignment.lecture)
228+
c.ExecutePreprocessor.timeout = self.cell_timeout
223229
return c
224230

225231
def _get_whitelist_patterns(self) -> set[str]:
@@ -320,6 +326,41 @@ def _cleanup(self) -> None:
320326
if self.close_session:
321327
self.session.close()
322328

329+
def _determine_cell_timeout(self):
330+
cell_timeout = self.default_cell_timeout
331+
# check if the cell timeout was set by user
332+
if self.assignment.settings.cell_timeout is not None:
333+
custom_cell_timeout = self.assignment.settings.cell_timeout
334+
self.log.info(
335+
f"Found custom cell timeout in assignment settings: {custom_cell_timeout} seconds."
336+
)
337+
cell_timeout = min(
338+
self.max_cell_timeout, max(custom_cell_timeout, self.min_cell_timeout)
339+
)
340+
self.log.info(f"Setting custom cell timeout to {cell_timeout}.")
341+
342+
return cell_timeout
343+
344+
@validate("min_cell_timeout", "default_cell_timeout", "max_cell_timeout")
345+
def _validate_cell_timeouts(self, proposal):
346+
trait_name = proposal["trait"].name
347+
value = proposal["value"]
348+
349+
# Get current or proposed values
350+
min_t = value if trait_name == "min_cell_timeout" else self.min_cell_timeout
351+
default_t = value if trait_name == "default_cell_timeout" else self.default_cell_timeout
352+
max_t = value if trait_name == "max_cell_timeout" else self.max_cell_timeout
353+
354+
# Validate the constraint
355+
if not (0 < min_t < default_t < max_t):
356+
raise TraitError(
357+
f"Invalid {trait_name} value ({value}). "
358+
f"Timeout values must satisfy: 0 < min_cell_timeout < default_cell_timeout < max_cell_timeout. "
359+
f"Got min={min_t}, default={default_t}, max={max_t}."
360+
)
361+
362+
return value
363+
323364
@validate("relative_input_path", "relative_output_path")
324365
def _validate_service_dir(self, proposal):
325366
path: str = proposal["value"]
@@ -358,7 +399,7 @@ def _run(self):
358399
self.output_path,
359400
"-p",
360401
"*.ipynb",
361-
f"--ExecutePreprocessor.timeout={self.timeout_func(self.assignment.lecture)}",
402+
f"--ExecutePreprocessor.timeout={self.cell_timeout}",
362403
]
363404
self.log.info(f"Running {command}")
364405
process = subprocess.run(

grader_service/handlers/config.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,48 @@
1+
import traitlets.config
2+
3+
from grader_service.autograding.local_grader import LocalAutogradeExecutor
14
from grader_service.handlers.base_handler import GraderBaseHandler, authorize
25
from grader_service.orm.takepart import Scope
36
from grader_service.registry import VersionSpecifier, register_handler
47

58

9+
def _get_effective_executor_value(
10+
app_cfg: traitlets.config.Config, executor_class: type[LocalAutogradeExecutor], trait_name: str
11+
):
12+
"""
13+
Return the configured value for trait_name if present in app_cfg,
14+
otherwise return the class default pulled from the trait metadata.
15+
"""
16+
17+
# 1) retrieve the provided class field from the config
18+
executor_class_field = app_cfg.get(executor_class.__name__, None)
19+
20+
# executor_class_field may be None, or a Config object / dict-like. Use mapping access.
21+
if executor_class_field is not None and trait_name in executor_class_field:
22+
return executor_class_field.get(trait_name)
23+
24+
# 2) fallback to the trait's default from the class metadata
25+
return executor_class.class_traits()[trait_name].default()
26+
27+
628
@register_handler(path=r"\/api\/config\/?", version_specifier=VersionSpecifier.ALL)
729
class ConfigHandler(GraderBaseHandler):
830
"""
931
Handler class for requests to /config
1032
"""
1133

12-
@authorize([Scope.student, Scope.tutor, Scope.instructor])
34+
@authorize([Scope.tutor, Scope.instructor])
1335
async def get(self):
14-
"""
15-
Gathers useful config for the grader labextension and returns it.
16-
:return: config in dict
17-
"""
18-
self.write({})
36+
app_cfg = self.application.config
37+
executor_class = app_cfg.RequestHandlerConfig.autograde_executor_class
38+
39+
def resolve(name):
40+
return _get_effective_executor_value(app_cfg, executor_class, name)
41+
42+
self.write_json(
43+
{
44+
"default_cell_timeout": resolve("default_cell_timeout"),
45+
"min_cell_timeout": resolve("min_cell_timeout"),
46+
"max_cell_timeout": resolve("max_cell_timeout"),
47+
}
48+
)

grader_service/tests/autograding/test_local_grader.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
from unittest.mock import MagicMock, Mock, patch
77

88
import pytest
9+
import traitlets.traitlets
910

1011
from grader_service.autograding.local_grader import (
1112
LocalAutogradeExecutor,
1213
LocalAutogradeProcessExecutor,
1314
)
14-
from grader_service.orm import Assignment, Lecture
15+
from grader_service.orm import Assignment
1516
from grader_service.orm.submission import AutoStatus
1617

1718

@@ -223,8 +224,8 @@ def side_effect():
223224
def test_timeout_function_default(local_autograde_executor):
224225
"""Test default timeout function"""
225226

226-
timeout = local_autograde_executor.timeout_func(Lecture())
227-
assert timeout == 360 # Default timeout
227+
timeout = local_autograde_executor.cell_timeout
228+
assert timeout == 300 # Default timeout
228229

229230

230231
@patch("grader_service.autograding.local_grader.Session")
@@ -235,20 +236,34 @@ def test_timeout_function_default(local_autograde_executor):
235236
def test_timeout_function_custom(mock_git, mock_session_class, tmp_path, submission_123):
236237
"""Test custom timeout function"""
237238

238-
def custom_timeout(lecture):
239-
return 720
239+
custom_timeout = 720
240240

241241
executor = LocalAutogradeExecutor(
242242
grader_service_dir=str(tmp_path),
243243
submission=submission_123,
244244
close_session=False,
245-
timeout_func=custom_timeout,
245+
default_cell_timeout=custom_timeout,
246246
)
247247

248-
timeout = executor.timeout_func(Lecture())
248+
timeout = executor.cell_timeout
249249
assert timeout == 720
250250

251251

252+
def test_invalid_custom_default_timeout(tmp_path, submission_123):
253+
invalid_timeout = -1
254+
255+
executor = LocalAutogradeExecutor(
256+
grader_service_dir=str(tmp_path), submission=submission_123, close_session=False
257+
)
258+
with pytest.raises(traitlets.traitlets.TraitError) as exc_info:
259+
executor.default_cell_timeout = invalid_timeout
260+
assert exc_info.value.args[0] == (
261+
f"Invalid default_cell_timeout value ({invalid_timeout}). "
262+
"Timeout values must satisfy: 0 < min_cell_timeout < default_cell_timeout < max_cell_timeout. "
263+
f"Got min={executor.min_cell_timeout}, default={invalid_timeout}, max={executor.max_cell_timeout}."
264+
)
265+
266+
252267
def test_gradebook_writing(local_autograde_executor):
253268
"""Test that gradebook is written correctly"""
254269

@@ -302,7 +317,7 @@ def test_process_executor_run_failure(mock_run, process_executor):
302317
process_executor.output_path,
303318
"-p",
304319
"*.ipynb",
305-
"--ExecutePreprocessor.timeout=360",
320+
"--ExecutePreprocessor.timeout=300",
306321
]
307322

308323
mock_run.assert_called_once_with(

grader_service/tests/handlers/test_base_handler.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def test_assignment_serialization():
6464
"late_submission": None,
6565
"deadline": datetime.now(tz=timezone.utc).isoformat(),
6666
"group": None,
67+
"cell_timeout": None,
6768
"max_submissions": 1,
6869
"autograde_type": "unassisted",
6970
"allowed_files": None,

0 commit comments

Comments
 (0)