Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/course-teams.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,33 @@ If your grader image has dependencies beyond the standard library, or you want t
exactly the environment that runs in production, build and run the grader container
locally.

**Fast path: `grade-local`.** `grader_support/tools/grade-local` is a small
[cyclopts](https://cyclopts.readthedocs.io/) CLI that wraps everything below --
locating your `grade_*.py`, defaulting the submission to the grader's own
`answer.py`, building the image on request, and bind-mounting your grader directory
so edits take effect without a rebuild. Copy it into your own repo (e.g. `bin/grade-local`)
and run it from your repo root:

```bash
pip install cyclopts # or add as a dev dependency

# Build once (or pass --build to grade-local to build automatically):
docker build --build-arg GRADER_BASE_IMAGE=ghcr.io/openedx/xqueue-watcher-grader-base:latest \
-t my-course:local .

bin/grade-local unit-2/exercise-3/grader.py
bin/grade-local unit-2/exercise-3/grader.py path/to/submission.py
bin/grade-local unit-2/exercise-3/grader.py --debug # GRADER_DEBUG=1 trace
```

It assumes the layout used by every course repo so far: a top-level `graders/`
directory copied to `/graders/` in the image (`--graders-dir` overrides this), and
an image tag defaulting to `<repo-dir-name>:local` (`--image` overrides this). See
`graders-mit-600x` and `graders-mit-686x` for examples of repo-specific copies.

The rest of this section walks through what `grade-local` automates, useful if you
want to customize the flow or aren't using the standard layout.

**Step 1 — Build the base image** (once per xqueue-watcher checkout):

```bash
Expand Down
2 changes: 1 addition & 1 deletion grader_support/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Course teams create their own image `FROM grader-base` and add their grader scri

```dockerfile
# syntax=docker/dockerfile:1
ARG GRADER_BASE_IMAGE=ghcr.io/mitodl/xqueue-watcher-grader-base:latest
ARG GRADER_BASE_IMAGE=ghcr.io/openedx/xqueue-watcher-grader-base:latest
FROM ${GRADER_BASE_IMAGE}

# pip must run as root; the base image ends with USER grader.
Expand Down
37 changes: 27 additions & 10 deletions grader_support/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,27 @@ def main():
trans.install(names=None)
_dbg("gettext installed")

from . import run as run_module, graderutil
from .gradelib import EndTest

# Load the grader module to access test definitions, preprocessors, and
# input validators. The grader script is baked into this image.
#
# This load (and each run_module.run() call below) is wrapped in its own
# graderutil.module_isolation() so that any modules it causes to be
# imported -- the grader module itself, and any helper modules the grader
# script imports -- are purged from sys.modules once we're done with them.
# Without this, module-level mutable state in those modules could leak
# into the later staff-answer/submission runs even though those runs are
# separately isolated from each other, because module_isolation() only
# rolls back modules imported *after* it takes its snapshot.
_dbg(f"loading grader module from {grader_path!r}")
try:
spec = importlib.util.spec_from_file_location("grader_module", grader_path)
grader_module_obj = importlib.util.module_from_spec(spec)
spec.loader.exec_module(grader_module_obj)
grader = grader_module_obj.grader
with graderutil.module_isolation():
spec = importlib.util.spec_from_file_location("grader_module", grader_path)
grader_module_obj = importlib.util.module_from_spec(spec)
spec.loader.exec_module(grader_module_obj)
grader = grader_module_obj.grader
_dbg(f"grader module loaded OK, tests={len(list(grader.tests()))}")
except Exception:
_dbg("EXCEPTION loading grader module:")
Expand Down Expand Up @@ -123,15 +136,18 @@ def main():
sys.path.insert(0, "/tmp")
_dbg(f"sys.path[:4]={sys.path[:4]}")

from . import run as run_module
from .gradelib import EndTest

grader_name = os.path.splitext(os.path.basename(grader_path))[0]
_dbg(f"grader_name={grader_name!r}")

# Run the staff answer first to get expected outputs.
# Run the staff answer and the student submission as two isolated in-process
# imports of the grader module. Without module_isolation(), the second
# run_module.run() call below would hit Python's sys.modules cache instead of
# re-executing the grader module, silently reusing whatever mutable
# module-level state (generators, shared dicts/lists, gradelib.rand snapshotted
# via `from gradelib import *`) the first run left behind.
_dbg("running staff answer")
expected_output = run_module.run(grader_name, "answer", seed)
with graderutil.module_isolation():
expected_output = run_module.run(grader_name, "answer", seed)
Comment thread
blarghmatey marked this conversation as resolved.
_dbg(f"expected_output grader status={expected_output['grader']['status']!r}"
f" submission status={expected_output['submission']['status']!r}"
f" exceptions={expected_output['exceptions']}"
Expand All @@ -156,7 +172,8 @@ def main():

# Run the student submission.
_dbg("running student submission")
actual_output = run_module.run(grader_name, "submission", seed)
with graderutil.module_isolation():
actual_output = run_module.run(grader_name, "submission", seed)
_dbg(f"actual_output grader status={actual_output['grader']['status']!r}"
f" submission status={actual_output['submission']['status']!r}"
f" exceptions={actual_output['exceptions']}"
Expand Down
197 changes: 197 additions & 0 deletions grader_support/tools/grade-local
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""Run a course grader against a submission locally, in the real grader container.

Runs the grader through `docker run` against the course repo's own grader image
(built from its Dockerfile, which extends grader-base -- see
grader_support/Dockerfile.base and docs/course-teams.md), so local results match
production exactly: the container's ENTRYPOINT is
`python -m grader_support.entrypoint`, the same engine xqueue-watcher's
ContainerGrader invokes for real submissions.

Course teams: copy this file into your own repo's bin/ (e.g. `bin/grade-local`)
and run it from your repo root. It has no dependency on xqueue-watcher being
checked out locally -- everything it needs is baked into the Docker image.

The graders directory is bind-mounted read-only over the image's baked-in
/graders/, so local edits to grade_*.py/answer.py take effect immediately without
rebuilding the image. Only rebuild when your dependency manifest changes.

Requires: docker, and the `cyclopts` package (pip install cyclopts, or add it as
a dev dependency).
"""

import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Optional

import cyclopts

REPO_ROOT = Path.cwd()
DEFAULT_GRADERS_DIR = REPO_ROOT / "graders"
DEFAULT_IMAGE = f"{REPO_ROOT.name}:local"
BUILD_ARGS = ["--build-arg", "GRADER_BASE_IMAGE=ghcr.io/openedx/xqueue-watcher-grader-base:latest"]
BUILD_CMD = " ".join(["docker", "build", *BUILD_ARGS, "-t", DEFAULT_IMAGE, "."])

app = cyclopts.App(help=__doc__)


def _resolve_grader_path(grader: Path) -> Path:
grader = grader.resolve()
if grader.is_dir():
matches = sorted(grader.glob("grade_*.py"))
if len(matches) != 1:
raise ValueError(
f"expected exactly one grade_*.py in {grader}, found {len(matches)}: "
f"{[m.name for m in matches]}"
)
return matches[0]
return grader


def _container_grader_path(grader_path: Path, graders_dir: Path) -> str:
try:
rel = grader_path.relative_to(graders_dir)
except ValueError:
raise SystemExit(f"{grader_path} is not under {graders_dir} (the image only has /graders/).")
return f"/graders/{rel.as_posix()}"


def _image_exists(image: str) -> bool:
return subprocess.run(
["docker", "image", "inspect", image], capture_output=True
).returncode == 0


def _parse_json_output(stdout: str) -> dict:
"""Scan backwards for the last non-empty line, matching how containergrader.py
parses container/pod output -- earlier lines may be stderr noise (deprecation
warnings, print statements) interleaved into stdout."""
for line in reversed(stdout.splitlines()):
stripped = line.strip()
if stripped:
return json.loads(stripped)
raise SystemExit("docker run produced no output.")


@app.default
def run(
grader: Path,
submission: Optional[Path] = None,
*,
seed: int = 42,
lang: str = "en",
hide_output: bool = False,
debug: bool = False,
image: str = DEFAULT_IMAGE,
graders_dir: Path = DEFAULT_GRADERS_DIR,
build: bool = False,
json_output: bool = False,
) -> None:
"""Grade a submission against a grader and print the result.

Parameters
----------
grader: Path to a grade_*.py file, or the directory containing one.
submission: Path to the student submission. Defaults to the grader's own
answer.py, which is a useful smoke test for the grader itself.
seed: Random seed shared between the staff answer and the submission run.
lang: Language code, passed through as GRADER_LANGUAGE.
hide_output: Suppress per-test expected/actual output, matching the
HIDE_OUTPUT env var read by grader_support.entrypoint.
debug: Enable GRADER_DEBUG=1 and print the entrypoint's stderr trace.
image: Docker image tag to run. Defaults to "<repo-dir-name>:local". Build it
first (see BUILD_CMD) or pass --build.
graders_dir: Host directory bind-mounted read-only to /graders in the
container. Defaults to ./graders, matching the COPY in a typical course
Dockerfile (see docs/course-teams.md).
build: Build the image first if it's missing.
json_output: Print the raw result dict as JSON instead of a formatted report.
"""
grader_path = _resolve_grader_path(grader)
if not grader_path.is_file():
raise SystemExit(f"Grader file not found: {grader_path}")
graders_dir = graders_dir.resolve()
submission_path = (submission or grader_path.parent / "answer.py").resolve()
if not submission_path.is_file():
raise SystemExit(f"Submission file not found: {submission_path}")
container_grader_path = _container_grader_path(grader_path, graders_dir)

if not _image_exists(image):
if build:
subprocess.run(["docker", "build", *BUILD_ARGS, "-t", image, "."], cwd=REPO_ROOT, check=True)
else:
raise SystemExit(
f"Docker image {image!r} not found. Build it first:\n\n {BUILD_CMD}\n\n"
f"or re-run with --build to build it automatically."
)

submission_code = submission_path.read_text(encoding="utf-8")
Comment thread
blarghmatey marked this conversation as resolved.

docker_cmd = [
"docker", "run", "--rm",
"-e", f"SUBMISSION_CODE={submission_code}",
"-e", f"GRADER_LANGUAGE={lang}",
"-e", f"HIDE_OUTPUT={'1' if hide_output else ''}",
]
if debug:
docker_cmd += ["-e", "GRADER_DEBUG=1"]
docker_cmd += [
"-v", f"{graders_dir}:/graders:ro",
image,
container_grader_path, str(seed),
]

proc = subprocess.run(docker_cmd, capture_output=True, text=True)

if debug and proc.stderr:
print(proc.stderr, file=sys.stderr)

if proc.returncode != 0 or not proc.stdout.strip():
print(f"docker run exited {proc.returncode} with no valid output.", file=sys.stderr)
if proc.stderr:
print(proc.stderr, file=sys.stderr)
raise SystemExit(1)

result = _parse_json_output(proc.stdout)

if json_output:
print(json.dumps(result, indent=2))
else:
_print_report(grader_path, submission_path, result)

if result["errors"] or not result["correct"]:
raise SystemExit(1)


def _print_report(grader_path: Path, submission_path: Path, result: dict) -> None:
print(f"grader: {grader_path}")
print(f"submission: {submission_path}")
print()

for error in result["errors"]:
print(f"ERROR: {error}")
if result["errors"]:
print()

for short_desc, long_desc, correct, expected, actual in result["tests"]:
status = "PASS" if correct else "FAIL"
print(f"[{status}] {short_desc}")
if long_desc:
print(f" {long_desc}")
if not correct:
print(" expected:")
for line in expected.splitlines():
print(f" {line}")
print(" actual:")
for line in actual.splitlines():
print(f" {line}")

print()
print(f"score: {result['score']} ({'correct' if result['correct'] else 'incorrect'})")


if __name__ == "__main__":
app()
Loading