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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,11 @@ templates
.remember/
tests/geec/
tests_submission/

# P-669 test fixtures — the broad rules above (*.csv, submissions, templates, config.toml)
# must not swallow committed test data.
!crates/scriptmark/tests/fixtures/**
# ...but not what running the suite over them produces.
crates/scriptmark/tests/fixtures/**/__pycache__/
crates/scriptmark/tests/fixtures/**/.scriptmark_extracted/
crates/scriptmark/tests/fixtures/**/.DS_Store
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ members = [
]

[workspace.package]
version = "0.2.0"
version = "0.3.0"
edition = "2024"
license = "GPL-3.0-or-later"
authors = ["Acture <acturea@gmail.com>"]
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,16 @@ results = scriptmark.grade(["submissions/"], "tests/")
for r in results:
print(f"{r.student_id}: {r.grade:.1f} ({r.passed}/{r.total})")

# Discover student files
subs = scriptmark.discover(["submissions/"]) # {'alice': ['path/to/alice_lab5.py'], ...}
# Discover student files (convenience view — drops non-submitters and orphan files)
# Keys are rendered student keys: a bare 学号 once a roster confirms it, otherwise
# `local:<token>` — the prefix means nothing has vouched for that filename token yet.
subs = scriptmark.discover(["submissions/"]) # {'local:alice': ['path/to/alice_lab5.py'], ...}

# The full input model: every student keeps an outcome, nothing is dropped
inp = scriptmark.load_input(["submissions/"], roster="roster.csv")
for s in inp["students"]:
print(s["identity"]["key"], s["state"]) # not_submitted | submitted_empty | executable
print(inp["unmatched"], inp["diagnostics"])

# Load and inspect a spec
spec = scriptmark.load_spec("tests/test_lab5.toml")
Expand Down
81 changes: 59 additions & 22 deletions crates/scriptmark-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ use std::path::Path;
use pyo3::prelude::*;
use pyo3::types::PyDict;

use scriptmark::discovery::discover_submissions;
use scriptmark::discovery::{LocalInputOptions, load_local_input};
use scriptmark::grading::apply_grading;
use scriptmark::models::{StudentReport, TestSpec};
use scriptmark::models::{AssignmentInput, StudentReport, TestSpec};
use scriptmark::runner::orchestrator::run_all;
use scriptmark::runner::python::PythonExecutor;
use scriptmark::spec_loader::load_specs_from_dir;
Expand Down Expand Up @@ -118,25 +118,65 @@ impl PyStudentResult {
/// Discover student submission files in the given directories.
///
/// Returns a dict mapping student IDs to lists of file paths.
///
/// This is a lossy convenience view: a dict keyed on student id cannot represent duplicate
/// identities, files with no identifiable owner, or roster members who did not submit. Use
/// `load_input()` when any of those matter.
#[pyfunction]
fn discover(paths: Vec<String>) -> PyResult<HashMap<String, Vec<String>>> {
let path_refs: Vec<&Path> = paths.iter().map(|p| Path::new(p.as_str())).collect();
let subs = discover_submissions(&path_refs, None)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;

Ok(subs
.by_student
.into_iter()
.map(|(sid, files)| {
let paths = files
.into_iter()
let input = local_input(&paths)?;

Ok(input
.students
.iter()
.filter(|s| !s.files().is_empty())
.map(|student| {
let files = student
.files()
.iter()
.map(|f| f.path.to_string_lossy().to_string())
.collect();
(sid, paths)
(student.identity.key.to_string(), files)
})
.collect())
}

/// Load the full unified input model as a dict.
///
/// Unlike `discover()`, nothing is dropped: every student carries an outcome
/// (`executable`, `submitted_empty`, `received_unmatched`, `not_submitted`), unattributable
/// files appear under `unmatched`, and anomalies appear under `diagnostics`.
#[pyfunction]
#[pyo3(signature = (paths, *, roster=None))]
fn load_input(py: Python<'_>, paths: Vec<String>, roster: Option<String>) -> PyResult<PyObject> {
let roster = match roster {
Some(path) => Some(
scriptmark::roster::load_roster(Path::new(&path))
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?,
),
None => None,
};
let path_refs: Vec<&Path> = paths.iter().map(|p| Path::new(p.as_str())).collect();
let input = load_local_input(
&path_refs,
LocalInputOptions {
roster: roster.as_ref(),
..Default::default()
},
)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;

let json_val = serde_json::to_value(&input)
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
json_to_py(py, &json_val)
}

fn local_input(paths: &[String]) -> PyResult<AssignmentInput> {
let path_refs: Vec<&Path> = paths.iter().map(|p| Path::new(p.as_str())).collect();
load_local_input(&path_refs, LocalInputOptions::default())
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
}

/// Load a test specification from a TOML file.
#[pyfunction]
fn load_spec(path: String) -> PyResult<PyTestSpec> {
Expand All @@ -147,7 +187,7 @@ fn load_spec(path: String) -> PyResult<PyTestSpec> {
Ok(PyTestSpec { inner: spec })
}

/// Run tests for all students, returning raw results as dicts.
/// Run tests for all students, returning a list of raw result dicts.
#[pyfunction]
#[pyo3(signature = (submissions, tests, *, timeout=10, python="python3"))]
fn run(
Expand Down Expand Up @@ -175,7 +215,7 @@ fn grade(
python: &str,
policy: &str,
) -> PyResult<Vec<PyStudentResult>> {
let results = run_grading(&submissions, &tests, timeout, python)?;
let mut reports = run_grading(&submissions, &tests, timeout, python)?;

// Apply grading policy
let grading_policy =
Expand All @@ -184,7 +224,6 @@ fn grade(
lower: 60.0,
upper: 100.0,
});
let mut reports: Vec<StudentReport> = results.into_values().collect();
apply_grading(&mut reports, &grading_policy);

reports.sort_by(|a, b| a.student_id.cmp(&b.student_id));
Expand All @@ -200,10 +239,8 @@ fn run_grading(
tests: &str,
timeout: u64,
python: &str,
) -> PyResult<HashMap<String, StudentReport>> {
let path_refs: Vec<&Path> = submissions.iter().map(|p| Path::new(p.as_str())).collect();
let subs = discover_submissions(&path_refs, None)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
) -> PyResult<Vec<StudentReport>> {
let input = local_input(submissions)?;

let specs = load_specs_from_dir(Path::new(tests))
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
Expand All @@ -214,8 +251,7 @@ fn run_grading(
let rt = tokio::runtime::Runtime::new()
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;

let results = rt.block_on(run_all(&subs, &specs, &executor, timeout, None));
Ok(results)
Ok(rt.block_on(run_all(&input.students, &specs, &executor, timeout, None)))
}

/// Convert serde_json::Value to a Python object.
Expand Down Expand Up @@ -255,6 +291,7 @@ fn _scriptmark(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyTestSpec>()?;
m.add_class::<PyStudentResult>()?;
m.add_function(wrap_pyfunction!(discover, m)?)?;
m.add_function(wrap_pyfunction!(load_input, m)?)?;
m.add_function(wrap_pyfunction!(load_spec, m)?)?;
m.add_function(wrap_pyfunction!(run, m)?)?;
m.add_function(wrap_pyfunction!(grade, m)?)?;
Expand Down
Loading
Loading