chalk-grader 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """
2
+ Grade code answers in a sandbox with data profiles.
3
+ """
@@ -0,0 +1,70 @@
1
+ """
2
+ Reasons a grader gives no score to an answer.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from enum import Enum
8
+ from typing import Literal
9
+
10
+ from rich.console import RenderableType
11
+ from rich.text import Text
12
+
13
+ __all__ = ["Disqualified", "Timeout", "InvalidInput", "Error", "BuildError"]
14
+
15
+
16
+ class Disqualified(Enum):
17
+ """
18
+ An enumeration of reasons why a response might be disqualified.
19
+ """
20
+
21
+ TIMEOUT = "The response took too long to execute."
22
+ INVALID_INPUT = "The response contains text with an invalid syntax."
23
+ ERROR = "Internal error during grading."
24
+ BUILD_ERROR = "The response does not compile."
25
+
26
+ def message(self) -> str:
27
+ return self.value
28
+
29
+ def render(self) -> str:
30
+ return self.name.lower().replace("_", "-")
31
+
32
+ def __str__(self) -> str:
33
+ return self.render()
34
+
35
+ def __repr__(self) -> str:
36
+ return f"{self.name.lower().title()}()"
37
+
38
+ def __bool__(self) -> Literal[False]:
39
+ return False
40
+
41
+ def __rich__(self) -> RenderableType:
42
+ return Text(self.render(), style="red")
43
+
44
+
45
+ def Timeout() -> Disqualified:
46
+ """
47
+ Indicates that a response was disqualified due to a timeout during grading.
48
+ """
49
+ return Disqualified.TIMEOUT
50
+
51
+
52
+ def InvalidInput() -> Disqualified:
53
+ """
54
+ Indicates that a response was disqualified due to invalid syntax or format.
55
+ """
56
+ return Disqualified.INVALID_INPUT
57
+
58
+
59
+ def Error() -> Disqualified:
60
+ """
61
+ Indicates that a response was disqualified due to an internal error during grading.
62
+ """
63
+ return Disqualified.ERROR
64
+
65
+
66
+ def BuildError() -> Disqualified:
67
+ """
68
+ Indicates that the response does not compile, so its tests could not run.
69
+ """
70
+ return Disqualified.BUILD_ERROR
chalk_grader/graded.py ADDED
@@ -0,0 +1,61 @@
1
+ """
2
+ The result of grading one answer.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from typing import Any, NamedTuple
9
+
10
+ from rich.columns import Columns
11
+ from rich.console import RenderableType
12
+ from rich.text import Text
13
+
14
+ from .score import Score, is_score, rich_render_percent
15
+
16
+ __all__ = ["DEFAULT_TIMEOUT", "CaseResult", "Graded"]
17
+
18
+ DEFAULT_TIMEOUT = 30.0
19
+ try:
20
+ DEFAULT_TIMEOUT = float(os.environ.get("CHALK_AUTOGRADE_TIMEOUT", "30"))
21
+ except ValueError:
22
+ pass
23
+
24
+
25
+ class CaseResult(NamedTuple):
26
+ """
27
+ The outcome of one expected test of a run.
28
+ """
29
+
30
+ #: (classname, name) of the test in the JUnit report, or the case id of a
31
+ #: stdio report.
32
+ key: tuple[str, str]
33
+ #: The name shown in the feedback.
34
+ name: str
35
+ passed: bool
36
+ #: Why the test failed: its message, or "(not run)".
37
+ message: str | None = None
38
+
39
+
40
+ class Graded(NamedTuple):
41
+ #: A score or a disqualification (chalk_grader.disqualification, or a
42
+ #: richer type of the caller with the same interface).
43
+ score: Score | Any
44
+ feedback: str | None = None
45
+ #: Notes for the teacher, such as signs of tampering. Never shown to
46
+ #: the student.
47
+ notes: str | None = None
48
+ #: One entry per expected test, in the order of the reference run, when
49
+ #: the result came from a test report. None otherwise (a script, a build
50
+ #: error, a timeout).
51
+ cases: tuple[CaseResult, ...] | None = None
52
+
53
+ def __rich__(self) -> RenderableType:
54
+ parts: list[RenderableType] = []
55
+ if is_score(self.score):
56
+ parts.append(rich_render_percent(self.score))
57
+ else:
58
+ parts.append(self.score.__rich__())
59
+ if self.feedback:
60
+ parts.append(Text(f"({self.feedback})", style="dim"))
61
+ return Columns(parts) if len(parts) > 1 else parts[0]
chalk_grader/models.py ADDED
@@ -0,0 +1,410 @@
1
+ """
2
+ Configuration of an autograder: the `autograder` block of a question, the
3
+ profiles and their steps.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+ from typing import Annotated, Literal
10
+
11
+ from pydantic import BaseModel, ByteSize, ConfigDict, Field, model_validator
12
+
13
+ __all__ = ["Limits", "Stage", "ProfileStep", "PrepareStep", "Service", "Profile", "Autograder"]
14
+
15
+
16
+ class Limits(BaseModel):
17
+ """
18
+ Resource limits of an autograder run. Sizes accept units, as in "512MB"
19
+ or "1GiB".
20
+ """
21
+
22
+ memory: Annotated[
23
+ ByteSize | None,
24
+ Field(
25
+ description=(
26
+ "Address space limit of the script and each process it starts (RLIMIT_AS). "
27
+ "Runtimes that reserve large virtual areas, such as Node and the JVM, need "
28
+ "a generous value."
29
+ ),
30
+ ),
31
+ ] = None
32
+ file_size: Annotated[
33
+ ByteSize | None,
34
+ Field(description="Largest file the script or its processes can write (RLIMIT_FSIZE)."),
35
+ ] = None
36
+ answer_size: Annotated[
37
+ ByteSize,
38
+ Field(description="Largest text answer that is graded. Larger answers are rejected."),
39
+ ] = ByteSize(1024 * 1024)
40
+
41
+
42
+ class Stage(BaseModel):
43
+ """
44
+ Options of one step of a profile, overriding the autograder's.
45
+ """
46
+
47
+ timeout: Annotated[
48
+ float | None,
49
+ Field(description="Timeout of the step, in seconds."),
50
+ ] = None
51
+ limits: Annotated[
52
+ Limits | None,
53
+ Field(description="Resource limits of the step."),
54
+ ] = None
55
+
56
+
57
+ class ProfileStep(Stage):
58
+ """
59
+ A step of a profile that runs in the sandbox, once per student.
60
+ """
61
+
62
+ model_config = ConfigDict(extra="forbid")
63
+
64
+ command: Annotated[
65
+ str,
66
+ Field(description="Shell command of the step, run in the working directory."),
67
+ ]
68
+ seed: Annotated[
69
+ dict[str, str],
70
+ Field(
71
+ description=(
72
+ "Paths of the working directory filled with a copy of a path, usually in "
73
+ "the cache, before the step. The student's files are touched after the copy, "
74
+ "so build tools that compare modification times rebuild them."
75
+ ),
76
+ ),
77
+ ] = {}
78
+
79
+
80
+ class PrepareStep(BaseModel):
81
+ """
82
+ The trusted step of a profile, run once per cache with the network on.
83
+ """
84
+
85
+ model_config = ConfigDict(extra="forbid")
86
+
87
+ commands: Annotated[
88
+ list[str],
89
+ Field(description="Shell commands run in order, outside the sandbox."),
90
+ ] = []
91
+ timeout: Annotated[
92
+ float | None,
93
+ Field(description="Timeout of each command, in seconds."),
94
+ ] = None
95
+
96
+
97
+ class Service(BaseModel):
98
+ """
99
+ A program the student's code talks to through the network, such as an
100
+ HTTP server, started before the test step of each run in a sandbox of
101
+ its own.
102
+ """
103
+
104
+ model_config = ConfigDict(extra="forbid")
105
+
106
+ command: Annotated[
107
+ str,
108
+ Field(
109
+ description=(
110
+ "Shell command that starts the service, run in a copy of the template. It "
111
+ "listens on 127.0.0.1 at `port` and may write a JUnit report to "
112
+ "$CHALK_RESULT when it gets SIGTERM; its tests count in the grade."
113
+ ),
114
+ ),
115
+ ]
116
+ port: Annotated[
117
+ int,
118
+ Field(ge=1, le=65535, description="TCP port of the service, the same for the student."),
119
+ ]
120
+ env: Annotated[
121
+ dict[str, str],
122
+ Field(description="Environment variables of the service."),
123
+ ] = {}
124
+ start_timeout: Annotated[
125
+ float,
126
+ Field(gt=0, description="Seconds the service has to open the port."),
127
+ ] = 10
128
+
129
+
130
+ class Profile(BaseModel):
131
+ """
132
+ Grading recipe for one ecosystem, written as data: the commands of each
133
+ step, the environment, the files the template controls and how the
134
+ report is read. See docs/user-manual/autograding-scripts.md.
135
+ """
136
+
137
+ model_config = ConfigDict(extra="forbid")
138
+
139
+ description: str = ""
140
+ extends: Annotated[
141
+ str | None,
142
+ Field(description="Profile whose keys this one inherits. Tables are merged."),
143
+ ] = None
144
+ key: Annotated[
145
+ list[str],
146
+ Field(
147
+ description=(
148
+ "Template files the profile needs, such as lock files. Their contents, the "
149
+ "probes and the prepare options select the cache directory."
150
+ ),
151
+ ),
152
+ ] = []
153
+ trusted: Annotated[
154
+ list[str],
155
+ Field(
156
+ description=(
157
+ "Files and directories of the template that replace the student's copies, "
158
+ "or are removed from the student's project when the template lacks them."
159
+ ),
160
+ ),
161
+ ] = []
162
+ tests: Annotated[
163
+ list[str],
164
+ Field(description="Default test directories or files of the template."),
165
+ ] = ["tests"]
166
+ probe: Annotated[
167
+ dict[str, str],
168
+ Field(
169
+ description=(
170
+ "Variables set from the output of shell commands run outside the sandbox, "
171
+ "such as the path of a toolchain."
172
+ ),
173
+ ),
174
+ ] = {}
175
+ env: Annotated[
176
+ dict[str, str],
177
+ Field(description="Environment variables of every step."),
178
+ ] = {}
179
+ unset: Annotated[
180
+ list[str],
181
+ Field(description="Environment variables removed in every step."),
182
+ ] = []
183
+ readable: Annotated[
184
+ list[str],
185
+ Field(description="Paths the sandbox mounts read only, such as the cache."),
186
+ ] = ["{cache}"]
187
+ hide: Annotated[
188
+ list[str],
189
+ Field(
190
+ description=(
191
+ "Glob patterns of files removed from the working directory before the "
192
+ "build and test steps, such as the expected outputs of the stdio profile."
193
+ ),
194
+ ),
195
+ ] = []
196
+ vars: Annotated[
197
+ dict[str, str],
198
+ Field(description="Variables for the other keys. Questions override them with `vars`."),
199
+ ] = {}
200
+ files: Annotated[
201
+ dict[str, str],
202
+ Field(description="Files written to the cache directory: name -> content."),
203
+ ] = {}
204
+ prepare: PrepareStep = Field(default_factory=PrepareStep)
205
+ suspicious: Annotated[
206
+ dict[str, dict[str, str]],
207
+ Field(
208
+ description=(
209
+ "Signs of tampering searched in the student's files: glob -> {reason: regex}. "
210
+ "Matches become notes for the teacher; the score does not change."
211
+ ),
212
+ ),
213
+ ] = {}
214
+ services: Annotated[
215
+ dict[str, Service],
216
+ Field(
217
+ description=(
218
+ "Services started for the test step, each in its own sandbox. The student's "
219
+ "code reaches them at 127.0.0.1 on their ports, and cannot see their files or "
220
+ "processes."
221
+ ),
222
+ ),
223
+ ] = {}
224
+ build: ProfileStep | None = None
225
+ test: ProfileStep
226
+ report: Annotated[
227
+ Literal["junit", "stdio"],
228
+ Field(
229
+ description=(
230
+ "Format of the report the test step writes to $CHALK_RESULT: 'junit', or "
231
+ "'stdio' for the outputs of chalk's stdio runner, compared by chalk with the "
232
+ "template's .out files according to the `compare` variable."
233
+ ),
234
+ ),
235
+ ] = "junit"
236
+
237
+
238
+ class Autograder(BaseModel):
239
+ answer: Annotated[
240
+ str,
241
+ Field(
242
+ description=(
243
+ "The path in the local grading repository in which the student's answer "
244
+ "is saved to before the grading process begins."
245
+ ),
246
+ ),
247
+ ] = "answer.txt"
248
+ timeout: Annotated[
249
+ float | None,
250
+ Field(
251
+ description=(
252
+ "Default timeout (in seconds) for the autograder to run at each response. This is "
253
+ "applicable to potentially CPU intensive grading tasks such as running test cases "
254
+ "or executing student code."
255
+ ),
256
+ ),
257
+ ] = None
258
+ syntax: Annotated[
259
+ bool | str | None,
260
+ Field(
261
+ description=(
262
+ "If true, the autograder will automatically apply syntax validators based on the "
263
+ "file extension of the answer or the given extension."
264
+ ),
265
+ ),
266
+ ] = None
267
+ script: Annotated[
268
+ Path | None,
269
+ Field(
270
+ description=(
271
+ "The path to a script that should be executed as part of the autograding process. "
272
+ "The path is given relative to the question repository. Absolute paths are relative to the root of the chalk project."
273
+ ),
274
+ ),
275
+ ] = None
276
+ template: Annotated[
277
+ Path | None,
278
+ Field(
279
+ description=(
280
+ "The path to a template file that should be used to generate the autograder script. "
281
+ "The path is given relative to the question repository. Absolute paths are relative to the root of the chalk project."
282
+ ),
283
+ ),
284
+ ] = None
285
+ pass_env: Annotated[
286
+ list[str],
287
+ Field(
288
+ description=(
289
+ "Environment variables passed from chalk to the autograder script, besides "
290
+ "PATH, HOME, locale and a few others. Everything else is dropped."
291
+ ),
292
+ ),
293
+ ] = []
294
+ profile: Annotated[
295
+ str | None,
296
+ Field(
297
+ description=(
298
+ "How the answer is graded. 'script' runs the autograder script; 'regex' "
299
+ "tests a regex answer against the examples in `match` and `reject`; "
300
+ "'python-pytest' runs the template tests with pytest; 'rust-cargo' builds "
301
+ "the crate and runs the template tests with cargo nextest. Other names refer "
302
+ "to the [profiles] tables of chalk.toml. By default, 'script' when the "
303
+ "question has a script."
304
+ ),
305
+ ),
306
+ ] = None
307
+ sandbox: Annotated[
308
+ Literal["none", "bwrap", "podman"] | None,
309
+ Field(
310
+ description=(
311
+ "Isolation of the autograder run. Scripts run in 'bwrap' by default; "
312
+ "'none' is for scripts that only read the answer. 'podman' is not "
313
+ "implemented yet."
314
+ ),
315
+ ),
316
+ ] = None
317
+ network: Annotated[
318
+ Literal["none", "host"],
319
+ Field(description="Network inside the sandbox: 'none' or the host network."),
320
+ ] = "none"
321
+ mounts: Annotated[
322
+ list[Path],
323
+ Field(
324
+ description=(
325
+ "Paths the sandbox mounts read only, for files under hidden directories "
326
+ "such as the home directory. Relative paths start at the question directory."
327
+ ),
328
+ ),
329
+ ] = []
330
+ limits: Limits = Field(default_factory=Limits)
331
+ match: Annotated[
332
+ list[str],
333
+ Field(description="regex profile: examples the answer must match."),
334
+ ] = []
335
+ reject: Annotated[
336
+ list[str],
337
+ Field(description="regex profile: examples the answer must not match."),
338
+ ] = []
339
+ regex_mode: Annotated[
340
+ Literal["full", "search"],
341
+ Field(
342
+ description=(
343
+ "regex profile: 'full' requires the regex to match the whole example, "
344
+ "'search' anywhere in it."
345
+ ),
346
+ ),
347
+ ] = "full"
348
+ tests: Annotated[
349
+ list[str] | None,
350
+ Field(
351
+ description=(
352
+ "Profiles that run tests: test directories or files of the template, by "
353
+ "default the profile's. They replace the student's copies and are read only "
354
+ "during the run."
355
+ ),
356
+ ),
357
+ ] = None
358
+ vars: Annotated[
359
+ dict[str, str],
360
+ Field(description="Profiles: values of the profile variables, such as `run` in stdio."),
361
+ ] = {}
362
+ build: Annotated[
363
+ Stage,
364
+ Field(description="Profiles with a build step: timeout and limits of the build."),
365
+ ] = Field(default_factory=Stage)
366
+ on_build_error: Annotated[
367
+ Literal["build-error", "zero", "manual"],
368
+ Field(
369
+ description=(
370
+ "Profiles with a build step: an answer that does not compile gets the 'build-error' "
371
+ "disqualification, a zero, or is left for manual grading. The compiler "
372
+ "output is the feedback."
373
+ ),
374
+ ),
375
+ ] = "build-error"
376
+ command: Annotated[
377
+ str | None,
378
+ Field(
379
+ description=(
380
+ "The command to execute as part of the autograding process. This can be used "
381
+ "as an alternative to specifying an external script."
382
+ ),
383
+ ),
384
+ ] = None
385
+ scoring: Annotated[
386
+ Literal["proportional", "all-or-nothing", "script"],
387
+ Field(
388
+ description=(
389
+ "Profiles with per-test results: 'proportional' scores passed / expected "
390
+ "tests, 'all-or-nothing' gives 1 when every expected test passed and 0 "
391
+ "otherwise, 'script' runs `scoring_script`."
392
+ ),
393
+ ),
394
+ ] = "proportional"
395
+ scoring_script: Annotated[
396
+ Path | None,
397
+ Field(
398
+ description=(
399
+ "scoring 'script': teacher script, relative to the folder of the configuration. "
400
+ "It reads the test results as JSON from stdin and prints the score, a decimal "
401
+ "or n/d in [0, 1]. It runs outside the sandbox."
402
+ ),
403
+ ),
404
+ ] = None
405
+
406
+ @model_validator(mode="after")
407
+ def _check_scoring(self) -> Autograder:
408
+ if (self.scoring == "script") != (self.scoring_script is not None):
409
+ raise ValueError("`scoring_script` goes together with `scoring: script`")
410
+ return self