diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6e0cba..6ee930b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,6 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - run: cargo fmt --check - - run: cargo clippy --all-targets -- -D warnings - - run: cargo test + - run: cargo fmt --all --check + - run: cargo clippy --workspace --all-targets -- -D warnings + - run: cargo test --workspace diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b7683c..b947ceb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,37 +57,6 @@ jobs: name: scriptmark-${{ matrix.target }} path: target/${{ matrix.target }}/release/${{ matrix.artifact }} - wheels: - name: Wheel ${{ matrix.target }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - target: x86_64-unknown-linux-gnu - os: ubuntu-latest - - target: x86_64-apple-darwin - os: macos-latest - - target: aarch64-apple-darwin - os: macos-latest - - target: x86_64-pc-windows-msvc - os: windows-latest - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - uses: PyO3/maturin-action@v1 - with: - target: ${{ matrix.target }} - args: --release --out dist - manylinux: auto - - uses: actions/upload-artifact@v4 - with: - name: wheel-${{ matrix.target }} - path: dist/*.whl - publish-github: name: GitHub Release needs: [build] @@ -134,26 +103,4 @@ jobs: - uses: dtolnay/rust-toolchain@stable - name: Publish crate - run: cargo publish -p scriptmark --no-verify - - publish-pypi: - name: Publish to PyPI - needs: [wheels] - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') - environment: release - permissions: - id-token: write - - steps: - - name: Download wheels - uses: actions/download-artifact@v4 - with: - path: dist - pattern: wheel-* - merge-multiple: true - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - packages-dir: dist/ + run: cargo publish --workspace diff --git a/.gitignore b/.gitignore index 98491f9..b18dd07 100644 --- a/.gitignore +++ b/.gitignore @@ -182,11 +182,15 @@ tests_submission/ # P-669 test fixtures — the broad rules above (*.csv, submissions, templates, config.toml) # must not swallow committed test data. -!src/crates/scriptmark/tests/fixtures/** +!src/core/tests/fixtures/** +!src/cli/tests/fixtures/** # ...but not what running the suite over them produces. -src/crates/scriptmark/tests/fixtures/**/__pycache__/ -src/crates/scriptmark/tests/fixtures/**/.scriptmark_extracted/ -src/crates/scriptmark/tests/fixtures/**/.DS_Store +src/core/tests/fixtures/**/__pycache__/ +src/cli/tests/fixtures/**/__pycache__/ +src/core/tests/fixtures/**/.scriptmark_extracted/ +src/cli/tests/fixtures/**/.scriptmark_extracted/ +src/core/tests/fixtures/**/.DS_Store +src/cli/tests/fixtures/**/.DS_Store # P-674 example bundles ship their students; `submissions` above would drop them. !examples/bundles/*/submissions/ diff --git a/CLAUDE.md b/CLAUDE.md index 1dc98ff..1191f3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,41 +55,40 @@ Automated grading CLI for student programming assignments. Rust core with custom ```bash cargo check # Fast type check cargo build -p scriptmark # Build CLI binary -cargo test -p scriptmark # Core and integration tests; requires Python -cargo test --workspace # Includes the PyO3 binding crate -cargo clippy --all-targets # Lint — must be 0 warnings +cargo test -p scriptmark-core # Core tests; requires Python +cargo test -p scriptmark # CLI and adapter integration tests +cargo test --workspace # Both crates +cargo clippy --workspace --all-targets -- -D warnings cargo fmt # Format all code cargo fmt --check # Verify formatting -maturin develop # Build + install PyO3 Python bindings locally ``` -Single test: `cargo test -p scriptmark test_name -- --nocapture`. -For the PyO3 crate, select a supported interpreter via `PYO3_PYTHON` when the machine's -default Python is newer than the PyO3 version supports; see the P-676 validation record. +Single test: `cargo test -p scriptmark-core test_name -- --nocapture` (or `-p scriptmark` +for CLI tests). Python 3 is required by the subprocess executor, not by a Python SDK. ## Architecture -Single `scriptmark` crate (lib + bin) with `scriptmark-py` as separate cdylib for PyO3. +Two Rust crates live directly under `src/`: `src/core/` contains `scriptmark-core`, +and `src/cli/` contains the `scriptmark` CLI and its adapters. The CLI depends on the +core; core must not depend on CLI, HTTP, SQLite or terminal UI libraries. -All product source is under `src/`: Rust packages live in `src/crates/` and the -Python package entry point in `src/python/scriptmark/`. Workspace/build configuration -stays at the repository root, where Cargo and Maturin commands are run. The module -paths below are relative to `src/crates/scriptmark/src/`. +Cargo workspace/build configuration stays at the repository root. There is no Python +package, PyO3 binding or Python wheel release. Embedded Python executor/checker code +and runnable Python assignment examples remain part of the Rust product. Data flows: TOML specs + student files → Runner → grading record (evidence + score revisions) → Display/DB/HTML/Canvas. `rescore` adds a revision from saved evidence. ``` -models/ Data models, TOML spec parsing, grading policies -discovery Student file discovery + ZIP extraction -runner/ PythonExecutor (subprocess), orchestrator, sandbox (setrlimit), +core/src/models/ Data models, TOML spec parsing, grading policies +core/src/discovery Student file discovery + archive extraction +core/src/runner/ PythonExecutor (subprocess), orchestrator, sandbox (setrlimit), parametrize, expander, oracle, linter -checker/ Checker trait (8 impls) + Rhai + Python checkers -record Grading record: versioned evidence, score revisions, rescore reuse checks -db/ SQLite (rusqlite bundled): students, sessions (= revisions), results, similarity -canvas/ Canvas LMS API client (reqwest + rustls): roster pull, grades push -tui/ ratatui terminal UI: students/sessions/similarity tabs -scriptmark-py PyO3 bindings: grade, run, discover, load_spec (maturin, separate crate) +core/src/checker/ Checker trait + Rhai + Python checkers +core/src/record Versioned evidence, score revisions, rescore reuse checks +cli/src/db/ SQLite: students, sessions (= revisions), results, similarity +cli/src/canvas/ Canvas HTTP client and offline bundle adapter +cli/src/tui/ ratatui terminal UI: students/sessions/similarity tabs ``` ## Key Design Decisions @@ -110,23 +109,24 @@ Zero and withheld grades stay distinct. See the notes' teacher contract for poli ## Critical Files -- `src/crates/scriptmark/src/runner/python.rs` — PythonExecutor + embedded helper scripts (HELPER_SCRIPT, CHAIN_HELPER_SCRIPT). Contains import allowlist, env sanitization, `sandboxed_cmd()`, `spawn_with_timeout()`. -- `src/crates/scriptmark/src/runner/sandbox.rs` — SandboxConfig + setrlimit application. Platform-conditional: RLIMIT_AS skipped on macOS, RLIMIT_* constants differ between Linux (c_uint) and macOS (c_int). -- `src/crates/scriptmark/src/runner/orchestrator.rs` — Runs vars→setup→expand→oracle→execute pipeline per student, tokio parallel. -- `src/crates/scriptmark/src/models/spec.rs` — All TOML spec structs (TestSpec, TestCase, SetupStep, Parametrize, Oracle, LintConfig, CheckMethod). -- `src/crates/scriptmark/src/discovery.rs` — File discovery + ZIP archive extraction with size/count limits. -- `src/crates/scriptmark/src/record.rs` — The grading record every consumer reads (`--output`): evidence with submission/spec fingerprints, append-only score revisions, `check()` refusing evidence whose tests, submissions or matching changed. -- `src/crates/scriptmark/src/grading.rs` — GradingPolicy dispatch (templates + Rhai formulas). -- `src/crates/scriptmark/src/main.rs` — All CLI command handlers. -- `src/crates/scriptmark-py/src/lib.rs` — PyO3 bindings: grade(), run(), discover(), load_spec(), StudentResult, TestSpec classes. +- `src/core/src/runner/python.rs` — PythonExecutor and embedded harness. Starts the isolated Python subprocess and checks its output. +- `src/core/src/runner/sandbox.rs` — SandboxConfig + setrlimit application. Platform-conditional: RLIMIT_AS skipped on macOS, RLIMIT_* constants differ between Linux (c_uint) and macOS (c_int). +- `src/core/src/runner/orchestrator.rs` — Runs prepared bundles per student, tokio parallel. +- `src/core/src/models/spec.rs` — TOML test specification types. +- `src/core/src/discovery.rs` — File discovery and archive extraction with size/count limits. +- `src/core/src/record.rs` — Versioned grading evidence and score revisions, including reuse checks. +- `src/core/src/grading.rs` — GradingPolicy dispatch (templates + Rhai formulas). +- `src/cli/src/main.rs` — CLI command handlers and application orchestration. ## Conventions - Workspace dependencies in root `Cargo.toml`, crates reference with `{ workspace = true }` - `thiserror` for library error types, `anyhow` for CLI/binary error handling -- All checkers implement `Checker` trait in `src/crates/scriptmark/src/checker/mod.rs` +- All checkers implement `Checker` trait in `src/core/src/checker/mod.rs` - Integration tests spawn real Python processes — need `python3` available -- `scriptmark-py` has `publish = false` (cdylib, distributed via PyPI/maturin, not crates.io) +- `scriptmark-core` exposes grading models and operations; CLI adapters import it directly, without compatibility re-exports. +- Core's `test-support` feature exposes shared graded/withheld report fixtures for adapter tests; it is enabled only by the CLI's dev-dependency. +- Publish both Rust packages with `cargo publish --workspace`, which orders workspace dependencies before their consumers. - Platform-specific code uses `#[cfg(target_os = "macos")]` / `#[cfg(target_os = "linux")]` for rlimit types ## TOML Spec Example diff --git a/Cargo.toml b/Cargo.toml index 0ed1bab..4efc021 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [workspace] resolver = "2" members = [ - "src/crates/scriptmark", - "src/crates/scriptmark-py", + "src/core", + "src/cli", ] [workspace.package] @@ -17,12 +17,12 @@ categories = ["command-line-utilities"] readme = "README.md" [workspace.dependencies] +scriptmark-core = { path = "src/core", version = "0.3.0" } # Serialization serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["float_roundtrip"] } toml = "0.8" csv = "1" -quick-xml = { version = "0.37", features = ["serialize"] } # Async tokio = { version = "1", features = ["full"] } @@ -58,9 +58,6 @@ libc = "0.2" rand = "0.9" rand_chacha = "0.9" -# Python bindings -pyo3 = { version = "0.24", features = ["extension-module"] } - ratatui = "0.29" crossterm = "0.29" rusqlite = { version = "0.34", features = ["bundled"] } diff --git a/README.md b/README.md index 2afca99..fbc6523 100644 --- a/README.md +++ b/README.md @@ -2,27 +2,24 @@ [![CI](https://img.shields.io/github/actions/workflow/status/Acture/scriptmark/ci.yml?label=CI)](https://github.com/Acture/scriptmark/actions) [![Crates.io](https://img.shields.io/crates/v/scriptmark)](https://crates.io/crates/scriptmark) -[![PyPI](https://img.shields.io/pypi/v/scriptmark)](https://pypi.org/project/scriptmark/) [![License](https://img.shields.io/crates/l/scriptmark)](https://spdx.org/licenses/GPL-3.0-or-later.html) -Automated grading CLI for student programming assignments. Rust core, TOML test specifications, Python bindings via PyO3. +Automated grading CLI for student programming assignments. Rust core and CLI with TOML test specifications. ## Repository layout ```text src/ - crates/ - scriptmark/ Rust library, CLI and integration tests - scriptmark-py/ Rust bindings for the Python extension - python/ - scriptmark/ Python package entry point + core/ scriptmark-core: grading engine and input/record models + cli/ scriptmark: CLI, Canvas, SQLite, TUI and presentation examples/ Runnable teacher test bundles notes/ Private project documentation submodule ``` -Run Cargo and Maturin commands from the repository root. `Cargo.toml` and -`pyproject.toml` configure both builds; the compiled Python extension is packaged -with `src/python/scriptmark/`. +Run Cargo commands from the repository root. The CLI depends on `scriptmark-core`; +the core has no CLI, network, database or terminal UI dependencies. Python 3 is still +required to execute student submissions, teacher checkers and reference solutions. +ScriptMark is distributed as a Rust CLI; it does not expose a Python package/API. ## Documentation and checkout @@ -120,8 +117,7 @@ repository; ScriptMark does not carry its own synchronization script. ## Installation ```bash -cargo install scriptmark # Rust -pip install scriptmark # Python +cargo install scriptmark ``` ## Quick Start @@ -203,44 +199,6 @@ scriptmark roster-pull --course-id 12345 scriptmark tui grades.db ``` -## Python API - -```python -import scriptmark - -# One-shot grading, under the assignment.toml beside tests/ (or pass assignment=...). -# freeze= keeps the generated inputs; replay= grades on ones kept earlier; output= writes -# the grading record. -results = scriptmark.grade( - ["submissions/"], "tests/", freeze="output/cases.json", output="output/results.json" -) -for r in results: - if r.grade is None: - print(f"{r.student_id}: withheld ({r.reason})") - else: - print(f"{r.student_id}: {r.grade} ({r.score}/{r.max} points)") - -# After editing the policy, score the record again without running anything -# (records graded from a Canvas bundle are rescored with the CLI) -change = scriptmark.rescore("output/results.json") # {"revision": 2, "changes": [...]} -first = scriptmark.load_record("output/results.json", revision=1) - -# 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:` — 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") -print(spec.name, spec.function, spec.num_cases) -``` - ## TOML Test Specs Teachers can configure student ownership, item file patterns, function aliases and diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 7d3f31e..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,26 +0,0 @@ -[build-system] -requires = ["maturin>=1.7,<2"] -build-backend = "maturin" - -[project] -name = "scriptmark" -version = "0.3.0" -description = "Automated grading CLI for student programming assignments" -readme = "README.md" -license = { text = "GPL-3.0-or-later" } -requires-python = ">=3.10" -authors = [{ name = "Acture", email = "acturea@gmail.com" }] -keywords = ["grading", "education", "autograding", "cli"] -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Education", - "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", - "Programming Language :: Rust", - "Programming Language :: Python :: Implementation :: CPython", -] - -[tool.maturin] -features = ["pyo3/extension-module"] -module-name = "scriptmark._scriptmark" -manifest-path = "src/crates/scriptmark-py/Cargo.toml" -python-source = "src/python" diff --git a/src/crates/scriptmark/Cargo.toml b/src/cli/Cargo.toml similarity index 72% rename from src/crates/scriptmark/Cargo.toml rename to src/cli/Cargo.toml index e32951e..ed58f79 100644 --- a/src/crates/scriptmark/Cargo.toml +++ b/src/cli/Cargo.toml @@ -9,44 +9,32 @@ repository.workspace = true homepage.workspace = true keywords.workspace = true categories.workspace = true -readme = "../../../README.md" +readme = "../../README.md" [[bin]] name = "scriptmark" path = "src/main.rs" [dependencies] +scriptmark-core = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -toml = { workspace = true } csv = { workspace = true } -quick-xml = { workspace = true } tokio = { workspace = true } clap = { workspace = true } -rhai = { workspace = true } comfy-table = { workspace = true } owo-colors = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } -regex = { workspace = true } -zip = { workspace = true } -rust_xlsxwriter = { workspace = true } -libc = { workspace = true } -rand = { workspace = true } -rand_chacha = { workspace = true } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } rusqlite = { workspace = true } ratatui = { workspace = true } crossterm = { workspace = true } -tar = "0.4.46" -flate2 = "1.1.10" -sevenz-rust2 = "0.23.0" -rars = "0.9.4" -bzip2-rs = "0.1.2" -lzma-rs = "0.3.0" -tempfile = "3" -sha2 = "0.10" [dev-dependencies] +scriptmark-core = { workspace = true, features = ["test-support"] } +tempfile = "3" +zip = { workspace = true } +toml = { workspace = true } calamine = { workspace = true } wiremock = "0.6.5" diff --git a/src/crates/scriptmark/src/canvas/bundle.rs b/src/cli/src/canvas/bundle.rs similarity index 94% rename from src/crates/scriptmark/src/canvas/bundle.rs rename to src/cli/src/canvas/bundle.rs index 4651097..56b95e5 100644 --- a/src/crates/scriptmark/src/canvas/bundle.rs +++ b/src/cli/src/canvas/bundle.rs @@ -19,12 +19,11 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; use super::client::{CanvasClient, CanvasError}; -use crate::archive; -use crate::input::canvas::{ +use scriptmark_core::input::canvas::{ CanvasAttachmentPayload, CanvasPayload, DownloadedAttachment, DownloadedAttachments, - ExpandedEntry, + expand_attachment, }; -use crate::models::InputDiagnostic; +use scriptmark_core::models::InputDiagnostic; const PAYLOAD_FILE: &str = "canvas-payload.json"; const MANIFEST_FILE: &str = "attachments.json"; @@ -121,25 +120,6 @@ fn distinct_attachments(payload: &CanvasPayload) -> BTreeMap) -> Vec { - if archive::format_of(path).is_none() { - return Vec::new(); - } - let stem = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("archive"); - let target = path.with_file_name(format!("{stem}.extracted")); - archive::expand(path, &target, &archive::is_gradeable, diagnostics) - .into_iter() - .map(|file| ExpandedEntry { - entry: file.entry, - path: file.out_path, - }) - .collect() -} - /// Fetch a course assignment into `root`, returning the payload that was saved. /// /// Listings are fetched first and are fatal on failure: the cohort is the thing being @@ -319,7 +299,7 @@ pub fn load( path.display() )), AttachmentRecord::Stored { path, .. } => Ok(DownloadedAttachment { - expanded: expansion_of(path, &mut diagnostics), + expanded: expand_attachment(path, &mut diagnostics), path: path.clone(), }), }; @@ -337,16 +317,19 @@ pub fn input_path(root: &Path) -> PathBuf { root.join("input.json") } -pub fn save_input(root: &Path, input: &crate::models::AssignmentInput) -> Result<(), BundleError> { +pub fn save_input( + root: &Path, + input: &scriptmark_core::models::AssignmentInput, +) -> Result<(), BundleError> { write_json(&input_path(root), input) } /// Fold the bundle's own diagnostics into a normalised input, keeping its ordering /// invariant intact. pub fn merge_diagnostics( - mut input: crate::models::AssignmentInput, + mut input: scriptmark_core::models::AssignmentInput, extra: Vec, -) -> crate::models::AssignmentInput { +) -> scriptmark_core::models::AssignmentInput { input.diagnostics.extend(extra); input.diagnostics.sort(); input.diagnostics.dedup(); diff --git a/src/crates/scriptmark/src/canvas/client.rs b/src/cli/src/canvas/client.rs similarity index 98% rename from src/crates/scriptmark/src/canvas/client.rs rename to src/cli/src/canvas/client.rs index ebbf496..992b1fe 100644 --- a/src/crates/scriptmark/src/canvas/client.rs +++ b/src/cli/src/canvas/client.rs @@ -6,7 +6,7 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::input::canvas::{ +use scriptmark_core::input::canvas::{ CanvasAssignmentPayload, CanvasCoursePayload, CanvasSubmissionPayload, CanvasUserPayload, }; @@ -426,7 +426,7 @@ mod tests { ]; CanvasClient::save_roster_csv(&users, &path).unwrap(); - let roster = crate::roster::load_roster(&path).unwrap(); + let roster = scriptmark_core::roster::load_roster(&path).unwrap(); // The comma and the quotes survive, and neither shifts a column. let alice = roster @@ -438,7 +438,7 @@ mod tests { // A SIS-less enrollee round-trips under their Canvas id rather than being dropped. let bob = roster - .lookup(&crate::models::StudentKey::CanvasUser(12346)) + .lookup(&scriptmark_core::models::StudentKey::CanvasUser(12346)) .map(|i| &roster.entries[i]) .expect("bob keyed by his Canvas id"); assert_eq!(bob.name.as_deref(), Some("Bob")); diff --git a/src/crates/scriptmark/src/canvas/mod.rs b/src/cli/src/canvas/mod.rs similarity index 100% rename from src/crates/scriptmark/src/canvas/mod.rs rename to src/cli/src/canvas/mod.rs diff --git a/src/crates/scriptmark/src/db/mod.rs b/src/cli/src/db/mod.rs similarity index 98% rename from src/crates/scriptmark/src/db/mod.rs rename to src/cli/src/db/mod.rs index 534f2ea..927da6a 100644 --- a/src/crates/scriptmark/src/db/mod.rs +++ b/src/cli/src/db/mod.rs @@ -70,10 +70,10 @@ impl Database { #[cfg(test)] mod tests { - use crate::models::fixtures::{graded, withheld}; - use crate::models::*; - use crate::roster::Roster; - use crate::similarity::SimilarityPair; + use scriptmark_core::models::fixtures::{graded, withheld}; + use scriptmark_core::models::*; + use scriptmark_core::roster::Roster; + use scriptmark_core::similarity::SimilarityPair; use super::*; diff --git a/src/crates/scriptmark/src/db/queries.rs b/src/cli/src/db/queries.rs similarity index 96% rename from src/crates/scriptmark/src/db/queries.rs rename to src/cli/src/db/queries.rs index 1958b1d..e6af9f6 100644 --- a/src/crates/scriptmark/src/db/queries.rs +++ b/src/cli/src/db/queries.rs @@ -1,4 +1,4 @@ -use crate::similarity::SimilarityPair; +use scriptmark_core::similarity::SimilarityPair; use super::{Database, DbError}; diff --git a/src/crates/scriptmark/src/db/results.rs b/src/cli/src/db/results.rs similarity index 97% rename from src/crates/scriptmark/src/db/results.rs rename to src/cli/src/db/results.rs index 7108609..7df37c0 100644 --- a/src/crates/scriptmark/src/db/results.rs +++ b/src/cli/src/db/results.rs @@ -1,7 +1,7 @@ use rusqlite::{OptionalExtension, Row}; -use crate::models::{GradeOutcome, Reason, StudentReport}; -use crate::record::Record; +use scriptmark_core::models::{GradeOutcome, Reason, StudentReport}; +use scriptmark_core::record::Record; use super::{Database, DbError}; @@ -83,12 +83,12 @@ impl ResultRow { pub fn grade_text(&self) -> String { match (self.state, self.final_grade) { (RowState::Graded, Some(grade)) => { - crate::export::number(grade, crate::export::POINTS_DECIMALS) + scriptmark_core::export::number(grade, scriptmark_core::export::POINTS_DECIMALS) } (RowState::Withheld, _) => format!( "- ({})", self.reason - .map(|r| crate::export::word(&r)) + .map(|r| scriptmark_core::export::word(&r)) .unwrap_or_default() ), _ => "-".into(), @@ -277,7 +277,7 @@ impl Database { report.canvas_user_id.map(|id| id as i64), report.pass_rate(), state, - grade.reason().map(|r| crate::export::word(&r)), + grade.reason().map(|r| scriptmark_core::export::word(&r)), score, grade.max, raw_grade, diff --git a/src/crates/scriptmark/src/db/roster.rs b/src/cli/src/db/roster.rs similarity index 98% rename from src/crates/scriptmark/src/db/roster.rs rename to src/cli/src/db/roster.rs index d4a84f1..41f59b3 100644 --- a/src/crates/scriptmark/src/db/roster.rs +++ b/src/cli/src/db/roster.rs @@ -1,5 +1,5 @@ use super::{Database, DbError}; -use crate::roster::Roster; +use scriptmark_core::roster::Roster; /// A student record from the database. #[derive(Debug, Clone)] diff --git a/src/crates/scriptmark/src/db/schema.rs b/src/cli/src/db/schema.rs similarity index 100% rename from src/crates/scriptmark/src/db/schema.rs rename to src/cli/src/db/schema.rs diff --git a/src/crates/scriptmark/src/display.rs b/src/cli/src/display.rs similarity index 98% rename from src/crates/scriptmark/src/display.rs rename to src/cli/src/display.rs index 5504e46..b506780 100644 --- a/src/crates/scriptmark/src/display.rs +++ b/src/cli/src/display.rs @@ -1,10 +1,10 @@ use comfy_table::{Cell, CellAlignment, Color, ContentArrangement, Table, presets::UTF8_FULL}; use owo_colors::OwoColorize; -use scriptmark::export; -use scriptmark::models::{ +use scriptmark_core::export; +use scriptmark_core::models::{ Fault, GradeOutcome, ItemOutcome, StudentReport, SubmissionOutcome, TestStatus, }; -use scriptmark::record::{Change, GradeState, Standing}; +use scriptmark_core::record::{Change, GradeState, Standing}; /// Display a summary table of all student results. pub fn display_summary(reports: &[&StudentReport], title: &str) { diff --git a/src/cli/src/lib.rs b/src/cli/src/lib.rs new file mode 100644 index 0000000..2fe77cc --- /dev/null +++ b/src/cli/src/lib.rs @@ -0,0 +1,5 @@ +//! Adapters used by the ScriptMark CLI. Grading logic lives in `scriptmark_core`. + +pub mod canvas; +pub mod db; +pub mod tui; diff --git a/src/crates/scriptmark/src/main.rs b/src/cli/src/main.rs similarity index 96% rename from src/crates/scriptmark/src/main.rs rename to src/cli/src/main.rs index b77aa36..bbe59b0 100644 --- a/src/crates/scriptmark/src/main.rs +++ b/src/cli/src/main.rs @@ -6,21 +6,21 @@ use std::sync::Arc; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; -use scriptmark::assignment::{self, Declared}; -use scriptmark::discovery::{LocalInputOptions, load_local_input}; -use scriptmark::grading::{self, Policy}; -use scriptmark::models::{ +use scriptmark_core::assignment::{self, Declared}; +use scriptmark_core::discovery::{LocalInputOptions, load_local_input}; +use scriptmark_core::grading::{self, Policy}; +use scriptmark_core::models::{ AssignmentInput, DiagnosticSeverity, StudentKey, StudentReport, StudentSubmission, SubmissionOutcome, TestSpec, }; -use scriptmark::record::{self, Evidence, Record, View}; -use scriptmark::roster::load_roster; -use scriptmark::runner::frozen::{self, Frozen, Generation}; -use scriptmark::runner::generation::SeedSource; -use scriptmark::runner::orchestrator::{self, RunOptions}; -use scriptmark::runner::prepare::prepare; -use scriptmark::runner::python::PythonExecutor; -use scriptmark::spec_loader::load_specs_from_dir; +use scriptmark_core::record::{self, Evidence, Record, View}; +use scriptmark_core::roster::load_roster; +use scriptmark_core::runner::frozen::{self, Frozen, Generation}; +use scriptmark_core::runner::generation::SeedSource; +use scriptmark_core::runner::orchestrator::{self, RunOptions}; +use scriptmark_core::runner::prepare::prepare; +use scriptmark_core::runner::python::PythonExecutor; +use scriptmark_core::spec_loader::load_specs_from_dir; #[derive(Parser)] #[command( @@ -524,7 +524,7 @@ fn report_input(input: &AssignmentInput) { } // 明确使用哪次提交: say so wherever a student is not being graded on their first try. - let later: Vec<&scriptmark::models::StudentSubmission> = input + let later: Vec<&scriptmark_core::models::StudentSubmission> = input .students .iter() .filter(|s| s.selected_attempt().is_some_and(|a| a.attempt > 1)) @@ -534,8 +534,8 @@ fn report_input(input: &AssignmentInput) { " {:>4} graded on a later attempt ({} policy)", later.len(), match input.attempt_policy { - scriptmark::models::AttemptPolicy::Latest => "latest", - scriptmark::models::AttemptPolicy::Earliest => "earliest", + scriptmark_core::models::AttemptPolicy::Latest => "latest", + scriptmark_core::models::AttemptPolicy::Earliest => "earliest", } ); for student in later { @@ -668,7 +668,7 @@ async fn run_bundles( tokio::select! { reports = orchestrator::run_all(students, bundles.into(), executor, &run_options) => Ok((reports, inputs, python)), _ = tokio::signal::ctrl_c() => { - scriptmark::runner::python::kill_all_units(); + scriptmark_core::runner::python::kill_all_units(); anyhow::bail!("interrupted: every running unit was stopped") } } @@ -698,7 +698,7 @@ struct Batch { input: AssignmentInput, specs: Vec, policy: Policy, - matching: scriptmark::matching::Config, + matching: scriptmark_core::matching::Config, /// Where it all was found, for the grading record. inputs: record::Inputs, } @@ -921,11 +921,11 @@ async fn cmd_grade(args: GradeArgs) -> Result<()> { frozen.write(&cases_path)?; println!("Inputs written to {}", cases_path.display()); } - let grades = scriptmark::export::grades(&record, revision)?; - scriptmark::export::write_csv(&grades, std::fs::File::create(&grades_path)?)?; + let grades = scriptmark_core::export::grades(&record, revision)?; + scriptmark_core::export::write_csv(&grades, std::fs::File::create(&grades_path)?)?; println!("Grades written to {}", grades_path.display()); - let cases = scriptmark::export::cases(reports); - scriptmark::export::write_csv(&cases, std::fs::File::create(&archive_path)?)?; + let cases = scriptmark_core::export::cases(reports); + scriptmark_core::export::write_csv(&cases, std::fs::File::create(&archive_path)?)?; println!("Archived to {}", archive_path.display()); } @@ -942,7 +942,7 @@ fn save_to_db( db_path: &Path, record: &Record, revision: u32, - roster: Option<&scriptmark::roster::Roster>, + roster: Option<&scriptmark_core::roster::Roster>, ) -> Result<()> { let database = scriptmark::db::Database::open(db_path).context("Failed to open database")?; if let Some(roster) = roster { @@ -1032,7 +1032,7 @@ fn cmd_match(args: MatchArgs) -> Result<()> { args.roster.as_ref(), Purpose::Preview, )?; - let preview = scriptmark::matching::preview(input, &specs, &matching, &args.python)?; + let preview = scriptmark_core::matching::preview(input, &specs, &matching, &args.python)?; if let Some(parent) = args.output.parent() { std::fs::create_dir_all(parent)?; } @@ -1136,10 +1136,10 @@ fn cmd_summarize(args: SummarizeArgs) -> Result<()> { } fn cmd_export(args: ExportArgs) -> Result<()> { - let format = scriptmark::export::Format::of(&args.output)?; + let format = scriptmark_core::export::Format::of(&args.output)?; let (record, view) = load_view(&args.results, args.revision.revision)?; let revision = scored(&view, &args.results)?; - let sheet = scriptmark::export::sheet(&record, revision, format)?; + let sheet = scriptmark_core::export::sheet(&record, revision, format)?; if let Some(parent) = args.output.parent().filter(|p| !p.as_os_str().is_empty()) { std::fs::create_dir_all(parent)?; } @@ -1189,7 +1189,7 @@ fn build_canvas_input( } } - let input = scriptmark::input::canvas::normalize( + let input = scriptmark_core::input::canvas::normalize( &payload, None, &downloads, @@ -1363,8 +1363,8 @@ async fn cmd_grades_push(args: GradesPushArgs) -> Result<()> { // Only graded students are pushed — a real 0 included, a withheld grade never. The // Canvas user id is the one import recorded: parsing student_id as an integer would // either fail for every 学号 or, worse, post to whichever user held that number. - let scriptmark::export::PushSet { grades, skipped } = - scriptmark::export::grades_to_push(&view.reports)?; + let scriptmark_core::export::PushSet { grades, skipped } = + scriptmark_core::export::grades_to_push(&view.reports)?; for (why, n) in &skipped { println!("Skipping {n} student(s): {why}"); } @@ -1387,7 +1387,7 @@ async fn cmd_grades_push(args: GradesPushArgs) -> Result<()> { } fn cmd_similarity(args: SimilarityArgs) -> Result<()> { - use scriptmark::similarity::compare_submissions; + use scriptmark_core::similarity::compare_submissions; // Collect files grouped by student ID let mut submissions: std::collections::HashMap> = @@ -1490,7 +1490,7 @@ fn cmd_report(args: ReportArgs) -> Result<()> { submissions.entry(sid).or_default().push(path); } } - Some(scriptmark::similarity::compare_submissions( + Some(scriptmark_core::similarity::compare_submissions( &submissions, 25, args.similarity_threshold, @@ -1532,8 +1532,8 @@ fn cmd_db(cmd: DbCommand) -> Result<()> { DbAction::ImportRoster { roster, db } => { let database = scriptmark::db::Database::open(&db).context("Failed to open database")?; - let roster_csv = - scriptmark::roster::load_roster(&roster).context("Failed to load roster CSV")?; + let roster_csv = scriptmark_core::roster::load_roster(&roster) + .context("Failed to load roster CSV")?; for diagnostic in &roster_csv.diagnostics { println!(" warning: {diagnostic}"); } diff --git a/src/crates/scriptmark/src/report.rs b/src/cli/src/report.rs similarity index 93% rename from src/crates/scriptmark/src/report.rs rename to src/cli/src/report.rs index e3fc718..7792f38 100644 --- a/src/crates/scriptmark/src/report.rs +++ b/src/cli/src/report.rs @@ -1,8 +1,8 @@ use std::path::Path; use anyhow::Result; -use scriptmark::models::StudentReport; -use scriptmark::similarity::SimilarityPair; +use scriptmark_core::models::StudentReport; +use scriptmark_core::similarity::SimilarityPair; /// Generate a self-contained HTML report from grading results. /// diff --git a/src/crates/scriptmark/src/report_template.html b/src/cli/src/report_template.html similarity index 100% rename from src/crates/scriptmark/src/report_template.html rename to src/cli/src/report_template.html diff --git a/src/crates/scriptmark/src/tui/app.rs b/src/cli/src/tui/app.rs similarity index 98% rename from src/crates/scriptmark/src/tui/app.rs rename to src/cli/src/tui/app.rs index 717dafc..97548f9 100644 --- a/src/crates/scriptmark/src/tui/app.rs +++ b/src/cli/src/tui/app.rs @@ -29,7 +29,7 @@ pub struct App { pub detail_json: Option, pub search: String, pub searching: bool, - pub similarity: Vec, + pub similarity: Vec, pub should_quit: bool, } diff --git a/src/crates/scriptmark/src/tui/mod.rs b/src/cli/src/tui/mod.rs similarity index 100% rename from src/crates/scriptmark/src/tui/mod.rs rename to src/cli/src/tui/mod.rs diff --git a/src/crates/scriptmark/src/tui/ui.rs b/src/cli/src/tui/ui.rs similarity index 88% rename from src/crates/scriptmark/src/tui/ui.rs rename to src/cli/src/tui/ui.rs index 5c9b612..383dfb1 100644 --- a/src/crates/scriptmark/src/tui/ui.rs +++ b/src/cli/src/tui/ui.rs @@ -169,27 +169,35 @@ fn draw_student_list(f: &mut Frame, area: Rect, app: &App) { fn draw_detail(f: &mut Frame, area: Rect, app: &App) { let mut lines: Vec = Vec::new(); if let Some(json) = &app.detail_json - && let Ok(report) = serde_json::from_str::(json) + && let Ok(report) = serde_json::from_str::(json) { if let Some(grade) = &report.grade { let head = match (&grade.outcome, grade.reason()) { ( - crate::models::GradeOutcome::Graded { + scriptmark_core::models::GradeOutcome::Graded { score, final_grade, .. }, reason, ) => format!( "graded {} ({}/{} points){}", - crate::export::number(*final_grade, grade.basis.decimals), - crate::export::number(*score, crate::export::POINTS_DECIMALS), - crate::export::number(grade.max, crate::export::POINTS_DECIMALS), + scriptmark_core::export::number(*final_grade, grade.basis.decimals), + scriptmark_core::export::number( + *score, + scriptmark_core::export::POINTS_DECIMALS + ), + scriptmark_core::export::number( + grade.max, + scriptmark_core::export::POINTS_DECIMALS + ), reason - .map(|r| format!(", {}", crate::export::word(&r))) + .map(|r| format!(", {}", scriptmark_core::export::word(&r))) .unwrap_or_default() ), - (crate::models::GradeOutcome::Withheld { .. }, reason) => format!( + (scriptmark_core::models::GradeOutcome::Withheld { .. }, reason) => format!( "withheld: {}", - reason.map(|r| crate::export::word(&r)).unwrap_or_default() + reason + .map(|r| scriptmark_core::export::word(&r)) + .unwrap_or_default() ), }; lines.push(Line::from(Span::styled( @@ -203,15 +211,18 @@ fn draw_detail(f: &mut Frame, area: Rect, app: &App) { .as_ref() .and_then(|g| g.items.iter().find(|i| i.item_id == tr.item_id)) .map(|i| match &i.outcome { - crate::models::ItemOutcome::Graded { score, .. } => { + scriptmark_core::models::ItemOutcome::Graded { score, .. } => { format!( " {}/{}", - crate::export::number(*score, crate::export::POINTS_DECIMALS), + scriptmark_core::export::number( + *score, + scriptmark_core::export::POINTS_DECIMALS + ), i.points ) } - crate::models::ItemOutcome::Withheld { reason, .. } => { - format!(" withheld: {}", crate::export::word(reason)) + scriptmark_core::models::ItemOutcome::Withheld { reason, .. } => { + format!(" withheld: {}", scriptmark_core::export::word(reason)) } }) .unwrap_or_default(); @@ -221,9 +232,9 @@ fn draw_detail(f: &mut Frame, area: Rect, app: &App) { ))); for case in &tr.cases { let (icon, color) = match case.status { - crate::models::TestStatus::Passed => ("v", Color::Green), - crate::models::TestStatus::Failed => ("x", Color::Red), - crate::models::TestStatus::Timeout => ("T", Color::Yellow), + scriptmark_core::models::TestStatus::Passed => ("v", Color::Green), + scriptmark_core::models::TestStatus::Failed => ("x", Color::Red), + scriptmark_core::models::TestStatus::Timeout => ("T", Color::Yellow), _ => ("!", Color::Red), }; lines.push(Line::from(vec![ diff --git a/src/crates/scriptmark/tests/canvas_fetch.rs b/src/cli/tests/canvas_fetch.rs similarity index 99% rename from src/crates/scriptmark/tests/canvas_fetch.rs rename to src/cli/tests/canvas_fetch.rs index 22e4977..22c45a4 100644 --- a/src/crates/scriptmark/tests/canvas_fetch.rs +++ b/src/cli/tests/canvas_fetch.rs @@ -214,7 +214,7 @@ async fn test_a_failed_download_writes_no_partial_file() { #[tokio::test] async fn test_a_bundle_round_trips_a_zip_attachment_and_a_failed_download() { use scriptmark::canvas::bundle; - use scriptmark::models::{ + use scriptmark_core::models::{ Assignment, AttemptPolicy, DiagnosticKind, FileOrigin, SubmissionOutcome, }; use std::sync::Arc; @@ -291,7 +291,7 @@ async fn test_a_bundle_round_trips_a_zip_attachment_and_a_failed_download() { let (payload, downloads, extra) = bundle::load(&root).unwrap(); assert_eq!(payload.assignment_name.as_deref(), Some("Lab 5")); - let input = scriptmark::input::canvas::normalize( + let input = scriptmark_core::input::canvas::normalize( &payload, None, &downloads, diff --git a/src/crates/scriptmark/tests/cli.rs b/src/cli/tests/cli.rs similarity index 99% rename from src/crates/scriptmark/tests/cli.rs rename to src/cli/tests/cli.rs index 1e7a916..a43e4f4 100644 --- a/src/crates/scriptmark/tests/cli.rs +++ b/src/cli/tests/cli.rs @@ -4,7 +4,7 @@ use std::path::Path; use std::process::{Command, Output}; -use scriptmark::runner::frozen::Frozen; +use scriptmark_core::runner::frozen::Frozen; const SPEC: &str = r#" [meta] diff --git a/src/crates/scriptmark/tests/examples.rs b/src/cli/tests/examples.rs similarity index 91% rename from src/crates/scriptmark/tests/examples.rs rename to src/cli/tests/examples.rs index 4d4ffcc..7995bcd 100644 --- a/src/crates/scriptmark/tests/examples.rs +++ b/src/cli/tests/examples.rs @@ -8,16 +8,16 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use scriptmark::discovery::{LocalInputOptions, load_local_input}; -use scriptmark::models::{Cause, Fault, GradeOutcome, StudentReport, TestStatus}; -use scriptmark::runner::frozen::Generation; -use scriptmark::runner::orchestrator::{RunOptions, run_all}; -use scriptmark::runner::prepare::prepare; -use scriptmark::runner::python::PythonExecutor; -use scriptmark::spec_loader::{load_spec, load_specs_from_dir}; +use scriptmark_core::discovery::{LocalInputOptions, load_local_input}; +use scriptmark_core::models::{Cause, Fault, GradeOutcome, StudentReport, TestStatus}; +use scriptmark_core::runner::frozen::Generation; +use scriptmark_core::runner::orchestrator::{RunOptions, run_all}; +use scriptmark_core::runner::prepare::prepare; +use scriptmark_core::runner::python::PythonExecutor; +use scriptmark_core::spec_loader::{load_spec, load_specs_from_dir}; fn examples() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../examples") + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples") } async fn grade_example(name: &str) -> Vec { @@ -34,9 +34,9 @@ async fn test_reference_oracle_example() { async fn grade_bundle(root: &Path) -> Vec { let specs = load_specs_from_dir(&root.join("tests")).unwrap_or_else(|e| panic!("{e}")); - let mut declared = scriptmark::assignment::load(None, &root.join("tests")).unwrap(); + let mut declared = scriptmark_core::assignment::load(None, &root.join("tests")).unwrap(); let policy = - scriptmark::assignment::settle(&mut declared.assignment, &declared.grading, &specs) + scriptmark_core::assignment::settle(&mut declared.assignment, &declared.grading, &specs) .unwrap_or_else(|e| panic!("{e}")); let submissions = root.join("submissions"); let input = load_local_input(&[submissions.as_path()], LocalInputOptions::default()).unwrap(); @@ -51,7 +51,7 @@ async fn grade_bundle(root: &Path) -> Vec { &RunOptions::default(), ) .await; - scriptmark::grading::grade_all(&mut reports, &declared.assignment.items, &policy) + scriptmark_core::grading::grade_all(&mut reports, &declared.assignment.items, &policy) .unwrap_or_else(|e| panic!("{e}")); reports } @@ -289,7 +289,8 @@ fn test_rescoring_example() { Some(""), ); - let record = scriptmark::record::Record::load(&dir.path().join("out/results.json")).unwrap(); + let record = + scriptmark_core::record::Record::load(&dir.path().join("out/results.json")).unwrap(); let grades = |revision: u32| { let reports = record.view(Some(revision)).unwrap().reports; ( diff --git a/src/crates/scriptmark/tests/fixtures/hw1/canvas/assignment.json b/src/cli/tests/fixtures/hw1/canvas/assignment.json similarity index 100% rename from src/crates/scriptmark/tests/fixtures/hw1/canvas/assignment.json rename to src/cli/tests/fixtures/hw1/canvas/assignment.json diff --git a/src/crates/scriptmark/tests/fixtures/hw1/canvas/files/1001/lab1.py b/src/cli/tests/fixtures/hw1/canvas/files/1001/lab1.py similarity index 100% rename from src/crates/scriptmark/tests/fixtures/hw1/canvas/files/1001/lab1.py rename to src/cli/tests/fixtures/hw1/canvas/files/1001/lab1.py diff --git a/src/crates/scriptmark/tests/fixtures/hw1/canvas/files/1002/draft.py b/src/cli/tests/fixtures/hw1/canvas/files/1002/draft.py similarity index 100% rename from src/crates/scriptmark/tests/fixtures/hw1/canvas/files/1002/draft.py rename to src/cli/tests/fixtures/hw1/canvas/files/1002/draft.py diff --git a/src/crates/scriptmark/tests/fixtures/hw1/canvas/files/1003/lab1.py b/src/cli/tests/fixtures/hw1/canvas/files/1003/lab1.py similarity index 100% rename from src/crates/scriptmark/tests/fixtures/hw1/canvas/files/1003/lab1.py rename to src/cli/tests/fixtures/hw1/canvas/files/1003/lab1.py diff --git a/src/crates/scriptmark/tests/fixtures/hw1/canvas/files/1004/lab1.py b/src/cli/tests/fixtures/hw1/canvas/files/1004/lab1.py similarity index 100% rename from src/crates/scriptmark/tests/fixtures/hw1/canvas/files/1004/lab1.py rename to src/cli/tests/fixtures/hw1/canvas/files/1004/lab1.py diff --git a/src/crates/scriptmark/tests/fixtures/hw1/canvas/files/1005/lab1.py b/src/cli/tests/fixtures/hw1/canvas/files/1005/lab1.py similarity index 100% rename from src/crates/scriptmark/tests/fixtures/hw1/canvas/files/1005/lab1.py rename to src/cli/tests/fixtures/hw1/canvas/files/1005/lab1.py diff --git a/src/crates/scriptmark/tests/fixtures/hw1/legacy_results.json b/src/cli/tests/fixtures/hw1/legacy_results.json similarity index 100% rename from src/crates/scriptmark/tests/fixtures/hw1/legacy_results.json rename to src/cli/tests/fixtures/hw1/legacy_results.json diff --git a/src/crates/scriptmark/tests/fixtures/hw1/local/roster.csv b/src/cli/tests/fixtures/hw1/local/roster.csv similarity index 100% rename from src/crates/scriptmark/tests/fixtures/hw1/local/roster.csv rename to src/cli/tests/fixtures/hw1/local/roster.csv diff --git a/src/crates/scriptmark/tests/fixtures/hw1/local/submissions/0024010003_lab1.py b/src/cli/tests/fixtures/hw1/local/submissions/0024010003_lab1.py similarity index 100% rename from src/crates/scriptmark/tests/fixtures/hw1/local/submissions/0024010003_lab1.py rename to src/cli/tests/fixtures/hw1/local/submissions/0024010003_lab1.py diff --git a/src/crates/scriptmark/tests/fixtures/hw1/local/submissions/2024010001_lab1.py b/src/cli/tests/fixtures/hw1/local/submissions/2024010001_lab1.py similarity index 100% rename from src/crates/scriptmark/tests/fixtures/hw1/local/submissions/2024010001_lab1.py rename to src/cli/tests/fixtures/hw1/local/submissions/2024010001_lab1.py diff --git a/src/crates/scriptmark/tests/fixtures/hw1/local/submissions/2024010002_lab1.py b/src/cli/tests/fixtures/hw1/local/submissions/2024010002_lab1.py similarity index 100% rename from src/crates/scriptmark/tests/fixtures/hw1/local/submissions/2024010002_lab1.py rename to src/cli/tests/fixtures/hw1/local/submissions/2024010002_lab1.py diff --git a/src/crates/scriptmark/tests/fixtures/hw1/local/submissions/2024010005_notes.txt b/src/cli/tests/fixtures/hw1/local/submissions/2024010005_notes.txt similarity index 100% rename from src/crates/scriptmark/tests/fixtures/hw1/local/submissions/2024010005_notes.txt rename to src/cli/tests/fixtures/hw1/local/submissions/2024010005_notes.txt diff --git a/src/crates/scriptmark/tests/fixtures/hw1/local/submissions/24010003_lab1.py b/src/cli/tests/fixtures/hw1/local/submissions/24010003_lab1.py similarity index 100% rename from src/crates/scriptmark/tests/fixtures/hw1/local/submissions/24010003_lab1.py rename to src/cli/tests/fixtures/hw1/local/submissions/24010003_lab1.py diff --git a/src/crates/scriptmark/tests/fixtures/hw1/local/submissions/_scratch_v2.py b/src/cli/tests/fixtures/hw1/local/submissions/_scratch_v2.py similarity index 100% rename from src/crates/scriptmark/tests/fixtures/hw1/local/submissions/_scratch_v2.py rename to src/cli/tests/fixtures/hw1/local/submissions/_scratch_v2.py diff --git a/src/crates/scriptmark/tests/grade_sheet.rs b/src/cli/tests/grade_sheet.rs similarity index 98% rename from src/crates/scriptmark/tests/grade_sheet.rs rename to src/cli/tests/grade_sheet.rs index 21de3a6..fa88aaa 100644 --- a/src/crates/scriptmark/tests/grade_sheet.rs +++ b/src/cli/tests/grade_sheet.rs @@ -7,10 +7,10 @@ use std::path::{Path, PathBuf}; use std::process::Command; use calamine::{Data, Reader, Xlsx}; -use scriptmark::record::Record; +use scriptmark_core::record::Record; fn example() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../examples/bundles/grade_sheet") + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/bundles/grade_sheet") } fn copy(from: &Path, to: &Path) { diff --git a/src/crates/scriptmark/tests/input_equivalence.rs b/src/cli/tests/input_equivalence.rs similarity index 97% rename from src/crates/scriptmark/tests/input_equivalence.rs rename to src/cli/tests/input_equivalence.rs index e9c83da..0e09965 100644 --- a/src/crates/scriptmark/tests/input_equivalence.rs +++ b/src/cli/tests/input_equivalence.rs @@ -7,15 +7,15 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use scriptmark::discovery::{LocalInputOptions, load_local_input}; -use scriptmark::input::canvas::{ +use scriptmark_core::discovery::{LocalInputOptions, load_local_input}; +use scriptmark_core::input::canvas::{ CanvasPayload, DownloadedAttachment, DownloadedAttachments, normalize, }; -use scriptmark::models::{ +use scriptmark_core::models::{ Assignment, AssignmentInput, AttemptPolicy, DiagnosticKind, ProjectedStudent, RosterMatch, StudentReport, SubmissionOutcome, UnmatchedReason, }; -use scriptmark::roster::{Roster, load_roster}; +use scriptmark_core::roster::{Roster, load_roster}; fn fixture_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hw1") @@ -344,6 +344,6 @@ fn test_results_written_before_per_item_grading_are_refused() { let raw = std::fs::read_to_string(fixture_root().join("legacy_results.json")).unwrap(); assert!(serde_json::from_str::>(&raw).is_err()); // Nor is it a grading record: a list of reports is refused by what it is. - let err = scriptmark::record::Record::from_json(&raw).unwrap_err(); + let err = scriptmark_core::record::Record::from_json(&raw).unwrap_err(); assert!(err.contains("before grading records"), "{err}"); } diff --git a/src/crates/scriptmark/tests/matching.rs b/src/cli/tests/matching.rs similarity index 95% rename from src/crates/scriptmark/tests/matching.rs rename to src/cli/tests/matching.rs index 2e677cd..7ceab20 100644 --- a/src/crates/scriptmark/tests/matching.rs +++ b/src/cli/tests/matching.rs @@ -4,10 +4,10 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Output}; -use scriptmark::discovery::{LocalInputOptions, load_local_input}; -use scriptmark::matching::{Config, OwnerOverride, State}; -use scriptmark::models::{Cause, StudentReport}; -use scriptmark::record::Record; +use scriptmark_core::discovery::{LocalInputOptions, load_local_input}; +use scriptmark_core::matching::{Config, OwnerOverride, State}; +use scriptmark_core::models::{Cause, StudentReport}; +use scriptmark_core::record::Record; const SPEC: &str = r#" [meta] @@ -373,8 +373,8 @@ fn invalid_rules_are_refused_before_execution() { #[test] fn canvas_source_identity_and_attachment_origin_survive_teacher_file_rules() { - use scriptmark::input::canvas::{CanvasPayload, DownloadedAttachment, normalize}; - use scriptmark::models::{Assignment, AttemptPolicy, FileOrigin}; + use scriptmark_core::input::canvas::{CanvasPayload, DownloadedAttachment, normalize}; + use scriptmark_core::models::{Assignment, AttemptPolicy, FileOrigin}; use std::collections::HashMap; let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hw1/canvas"); let payload: CanvasPayload = @@ -392,7 +392,7 @@ fn canvas_source_identity_and_attachment_origin_survive_teacher_file_rules() { AttemptPolicy::Latest, Assignment::default(), ); - let specs = [scriptmark::spec_loader::load_spec_str( + let specs = [scriptmark_core::spec_loader::load_spec_str( &SPEC.replace("solve.py", "lab1.py"), Path::new("."), ) @@ -403,7 +403,7 @@ fn canvas_source_identity_and_attachment_origin_survive_teacher_file_rules() { .iter() .find(|s| s.identity.canvas_user_id == Some(101)) .unwrap(); - let matched = scriptmark::matching::item_match(&config, student, &specs[0]); + let matched = scriptmark_core::matching::item_match(&config, student, &specs[0]); assert_eq!(matched.student, "2024010001"); assert_eq!( matched.owner.as_ref().unwrap().selected.as_deref(), diff --git a/src/crates/scriptmark/tests/record.rs b/src/cli/tests/record.rs similarity index 99% rename from src/crates/scriptmark/tests/record.rs rename to src/cli/tests/record.rs index f7e83d3..23a77bd 100644 --- a/src/crates/scriptmark/tests/record.rs +++ b/src/cli/tests/record.rs @@ -5,7 +5,7 @@ use std::path::Path; use std::process::{Command, Output}; -use scriptmark::record::Record; +use scriptmark_core::record::Record; const SPEC: &str = r#" [meta] @@ -244,7 +244,7 @@ fn rescoring_runs_nothing_and_keeps_the_earlier_revision() { assert!(text(&output).contains(words), "{}", text(&output)); } let after = record(dir); - let changes = scriptmark::record::diff(Some(&after.revisions[0]), &after.revisions[1]); + let changes = scriptmark_core::record::diff(Some(&after.revisions[0]), &after.revisions[1]); assert_eq!( changes .iter() diff --git a/src/core/Cargo.toml b/src/core/Cargo.toml new file mode 100644 index 0000000..548a725 --- /dev/null +++ b/src/core/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "scriptmark-core" +description = "Grading engine and evidence model for ScriptMark" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +readme = "../../README.md" + +[features] +test-support = [] + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +csv = { workspace = true } +tokio = { workspace = true } +rhai = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +regex = { workspace = true } +zip = { workspace = true } +rust_xlsxwriter = { workspace = true } +libc = { workspace = true } +rand = { workspace = true } +rand_chacha = { workspace = true } +tar = "0.4.46" +flate2 = "1.1.10" +sevenz-rust2 = "0.23.0" +rars = "0.9.4" +bzip2-rs = "0.1.2" +lzma-rs = "0.3.0" +tempfile = "3" +sha2 = "0.10" + +[dev-dependencies] +calamine = { workspace = true } diff --git a/src/crates/scriptmark/src/archive.rs b/src/core/src/archive.rs similarity index 100% rename from src/crates/scriptmark/src/archive.rs rename to src/core/src/archive.rs diff --git a/src/crates/scriptmark/src/assignment.rs b/src/core/src/assignment.rs similarity index 100% rename from src/crates/scriptmark/src/assignment.rs rename to src/core/src/assignment.rs diff --git a/src/crates/scriptmark/src/checker/builtin.rs b/src/core/src/checker/builtin.rs similarity index 100% rename from src/crates/scriptmark/src/checker/builtin.rs rename to src/core/src/checker/builtin.rs diff --git a/src/crates/scriptmark/src/checker/mod.rs b/src/core/src/checker/mod.rs similarity index 100% rename from src/crates/scriptmark/src/checker/mod.rs rename to src/core/src/checker/mod.rs diff --git a/src/crates/scriptmark/src/checker/python_checker.rs b/src/core/src/checker/python_checker.rs similarity index 100% rename from src/crates/scriptmark/src/checker/python_checker.rs rename to src/core/src/checker/python_checker.rs diff --git a/src/crates/scriptmark/src/checker/rhai_checker.rs b/src/core/src/checker/rhai_checker.rs similarity index 100% rename from src/crates/scriptmark/src/checker/rhai_checker.rs rename to src/core/src/checker/rhai_checker.rs diff --git a/src/crates/scriptmark/src/discovery.rs b/src/core/src/discovery.rs similarity index 100% rename from src/crates/scriptmark/src/discovery.rs rename to src/core/src/discovery.rs diff --git a/src/crates/scriptmark/src/export.rs b/src/core/src/export.rs similarity index 100% rename from src/crates/scriptmark/src/export.rs rename to src/core/src/export.rs diff --git a/src/crates/scriptmark/src/grading.rs b/src/core/src/grading.rs similarity index 100% rename from src/crates/scriptmark/src/grading.rs rename to src/core/src/grading.rs diff --git a/src/crates/scriptmark/src/input/canvas.rs b/src/core/src/input/canvas.rs similarity index 98% rename from src/crates/scriptmark/src/input/canvas.rs rename to src/core/src/input/canvas.rs index 82c1f79..f25f1bd 100644 --- a/src/crates/scriptmark/src/input/canvas.rs +++ b/src/core/src/input/canvas.rs @@ -4,7 +4,7 @@ //! download are P-670; this module defines the payload shapes that work lands in, so the //! HTTP layer can be written and tested against exactly what normalisation consumes. //! -//! The payload types live here rather than in [`crate::canvas::client`] on purpose: that +//! The payload types live here rather than in the CLI HTTP adapter on purpose: that //! module's `CanvasSubmission` is the *grade-push response* shape, and its types are not //! re-exported. @@ -13,6 +13,7 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +use crate::archive; use crate::discovery::detect_language; use crate::models::{ Assignment, AssignmentInput, Attachment, AttemptPolicy, DiagnosticKind, FileOrigin, @@ -21,6 +22,28 @@ use crate::models::{ }; use crate::roster::{Roster, RosterEntry, RosterSource}; +/// Expand an archive attachment beside itself, and map it into the input's shape. +pub fn expand_attachment( + path: &Path, + diagnostics: &mut Vec, +) -> Vec { + if archive::format_of(path).is_none() { + return Vec::new(); + } + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("archive"); + let target = path.with_file_name(format!("{stem}.extracted")); + archive::expand(path, &target, &archive::is_gradeable, diagnostics) + .into_iter() + .map(|file| ExpandedEntry { + entry: file.entry, + path: file.out_path, + }) + .collect() +} + /// A course user, as `GET /courses/:id/users` returns it. /// /// `sis_user_id` is `Option` and nothing else: a payload sending it unquoted is a @@ -95,7 +118,7 @@ pub struct CanvasAttachmentPayload { impl CanvasAttachmentPayload { /// The name Canvas reports. `pub(crate)` because the bundle writer and the offline /// loader both have to derive the same on-disk path from it — see `bundle::disk_name`. - pub(crate) fn name(&self) -> String { + pub fn name(&self) -> String { self.display_name .clone() .or_else(|| self.filename.clone()) diff --git a/src/crates/scriptmark/src/input/mod.rs b/src/core/src/input/mod.rs similarity index 100% rename from src/crates/scriptmark/src/input/mod.rs rename to src/core/src/input/mod.rs diff --git a/src/crates/scriptmark/src/lib.rs b/src/core/src/lib.rs similarity index 86% rename from src/crates/scriptmark/src/lib.rs rename to src/core/src/lib.rs index 8df43ff..9cd8018 100644 --- a/src/crates/scriptmark/src/lib.rs +++ b/src/core/src/lib.rs @@ -12,8 +12,5 @@ pub mod roster; pub mod similarity; pub mod spec_loader; -pub mod canvas; pub mod checker; -pub mod db; pub mod runner; -pub mod tui; diff --git a/src/crates/scriptmark/src/matching.py b/src/core/src/matching.py similarity index 100% rename from src/crates/scriptmark/src/matching.py rename to src/core/src/matching.py diff --git a/src/crates/scriptmark/src/matching.rs b/src/core/src/matching.rs similarity index 100% rename from src/crates/scriptmark/src/matching.rs rename to src/core/src/matching.rs diff --git a/src/crates/scriptmark/src/models/config.rs b/src/core/src/models/config.rs similarity index 100% rename from src/crates/scriptmark/src/models/config.rs rename to src/core/src/models/config.rs diff --git a/src/crates/scriptmark/src/models/mod.rs b/src/core/src/models/mod.rs similarity index 100% rename from src/crates/scriptmark/src/models/mod.rs rename to src/core/src/models/mod.rs diff --git a/src/crates/scriptmark/src/models/result.rs b/src/core/src/models/result.rs similarity index 98% rename from src/crates/scriptmark/src/models/result.rs rename to src/core/src/models/result.rs index 8dd7b8d..8bedded 100644 --- a/src/crates/scriptmark/src/models/result.rs +++ b/src/core/src/models/result.rs @@ -445,8 +445,8 @@ pub enum ItemOutcome { /// Reports with a grade already on them, for tests that are about what happens to a grade /// rather than how it was reached. -#[cfg(test)] -pub(crate) mod fixtures { +#[cfg(any(test, feature = "test-support"))] +pub mod fixtures { use super::*; fn basis() -> GradeBasis { @@ -458,7 +458,7 @@ pub(crate) mod fixtures { } } - pub(crate) fn graded(student_id: &str, final_grade: f64) -> StudentReport { + pub fn graded(student_id: &str, final_grade: f64) -> StudentReport { StudentReport { grade: Some(Grade { outcome: GradeOutcome::Graded { @@ -475,11 +475,7 @@ pub(crate) mod fixtures { } } - pub(crate) fn withheld( - student_id: &str, - state: SubmissionOutcome, - reason: Reason, - ) -> StudentReport { + pub fn withheld(student_id: &str, state: SubmissionOutcome, reason: Reason) -> StudentReport { StudentReport { grade: Some(Grade { outcome: GradeOutcome::Withheld { diff --git a/src/crates/scriptmark/src/models/spec.rs b/src/core/src/models/spec.rs similarity index 100% rename from src/crates/scriptmark/src/models/spec.rs rename to src/core/src/models/spec.rs diff --git a/src/crates/scriptmark/src/models/submission.rs b/src/core/src/models/submission.rs similarity index 100% rename from src/crates/scriptmark/src/models/submission.rs rename to src/core/src/models/submission.rs diff --git a/src/crates/scriptmark/src/record.rs b/src/core/src/record.rs similarity index 100% rename from src/crates/scriptmark/src/record.rs rename to src/core/src/record.rs diff --git a/src/crates/scriptmark/src/roster.rs b/src/core/src/roster.rs similarity index 100% rename from src/crates/scriptmark/src/roster.rs rename to src/core/src/roster.rs diff --git a/src/crates/scriptmark/src/runner/answers.rs b/src/core/src/runner/answers.rs similarity index 100% rename from src/crates/scriptmark/src/runner/answers.rs rename to src/core/src/runner/answers.rs diff --git a/src/crates/scriptmark/src/runner/executor.rs b/src/core/src/runner/executor.rs similarity index 100% rename from src/crates/scriptmark/src/runner/executor.rs rename to src/core/src/runner/executor.rs diff --git a/src/crates/scriptmark/src/runner/frozen.rs b/src/core/src/runner/frozen.rs similarity index 100% rename from src/crates/scriptmark/src/runner/frozen.rs rename to src/core/src/runner/frozen.rs diff --git a/src/crates/scriptmark/src/runner/generation.rs b/src/core/src/runner/generation.rs similarity index 100% rename from src/crates/scriptmark/src/runner/generation.rs rename to src/core/src/runner/generation.rs diff --git a/src/crates/scriptmark/src/runner/generator.rs b/src/core/src/runner/generator.rs similarity index 100% rename from src/crates/scriptmark/src/runner/generator.rs rename to src/core/src/runner/generator.rs diff --git a/src/crates/scriptmark/src/runner/harness.py b/src/core/src/runner/harness.py similarity index 100% rename from src/crates/scriptmark/src/runner/harness.py rename to src/core/src/runner/harness.py diff --git a/src/crates/scriptmark/src/runner/judge.rs b/src/core/src/runner/judge.rs similarity index 100% rename from src/crates/scriptmark/src/runner/judge.rs rename to src/core/src/runner/judge.rs diff --git a/src/crates/scriptmark/src/runner/linter.rs b/src/core/src/runner/linter.rs similarity index 100% rename from src/crates/scriptmark/src/runner/linter.rs rename to src/core/src/runner/linter.rs diff --git a/src/crates/scriptmark/src/runner/mod.rs b/src/core/src/runner/mod.rs similarity index 100% rename from src/crates/scriptmark/src/runner/mod.rs rename to src/core/src/runner/mod.rs diff --git a/src/crates/scriptmark/src/runner/oracle.rs b/src/core/src/runner/oracle.rs similarity index 100% rename from src/crates/scriptmark/src/runner/oracle.rs rename to src/core/src/runner/oracle.rs diff --git a/src/crates/scriptmark/src/runner/orchestrator.rs b/src/core/src/runner/orchestrator.rs similarity index 100% rename from src/crates/scriptmark/src/runner/orchestrator.rs rename to src/core/src/runner/orchestrator.rs diff --git a/src/crates/scriptmark/src/runner/prepare.rs b/src/core/src/runner/prepare.rs similarity index 100% rename from src/crates/scriptmark/src/runner/prepare.rs rename to src/core/src/runner/prepare.rs diff --git a/src/crates/scriptmark/src/runner/python.rs b/src/core/src/runner/python.rs similarity index 100% rename from src/crates/scriptmark/src/runner/python.rs rename to src/core/src/runner/python.rs diff --git a/src/crates/scriptmark/src/runner/records.rs b/src/core/src/runner/records.rs similarity index 100% rename from src/crates/scriptmark/src/runner/records.rs rename to src/core/src/runner/records.rs diff --git a/src/crates/scriptmark/src/runner/sandbox.rs b/src/core/src/runner/sandbox.rs similarity index 100% rename from src/crates/scriptmark/src/runner/sandbox.rs rename to src/core/src/runner/sandbox.rs diff --git a/src/crates/scriptmark/src/similarity.rs b/src/core/src/similarity.rs similarity index 100% rename from src/crates/scriptmark/src/similarity.rs rename to src/core/src/similarity.rs diff --git a/src/crates/scriptmark/src/spec_loader.rs b/src/core/src/spec_loader.rs similarity index 100% rename from src/crates/scriptmark/src/spec_loader.rs rename to src/core/src/spec_loader.rs diff --git a/src/crates/scriptmark/tests/fixtures/archives/m3_default.rar b/src/core/tests/fixtures/archives/m3_default.rar similarity index 100% rename from src/crates/scriptmark/tests/fixtures/archives/m3_default.rar rename to src/core/tests/fixtures/archives/m3_default.rar diff --git a/src/crates/scriptmark/tests/generated.rs b/src/core/tests/generated.rs similarity index 97% rename from src/crates/scriptmark/tests/generated.rs rename to src/core/tests/generated.rs index 13c2154..66fff99 100644 --- a/src/crates/scriptmark/tests/generated.rs +++ b/src/core/tests/generated.rs @@ -5,13 +5,13 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use scriptmark::models::*; -use scriptmark::runner::frozen::{Frozen, Generation}; -use scriptmark::runner::generation::{Origin, SeedSource}; -use scriptmark::runner::orchestrator::{RunOptions, run_all}; -use scriptmark::runner::prepare::{Bundle, prepare}; -use scriptmark::runner::python::PythonExecutor; -use scriptmark::spec_loader::load_spec_str; +use scriptmark_core::models::*; +use scriptmark_core::runner::frozen::{Frozen, Generation}; +use scriptmark_core::runner::generation::{Origin, SeedSource}; +use scriptmark_core::runner::orchestrator::{RunOptions, run_all}; +use scriptmark_core::runner::prepare::{Bundle, prepare}; +use scriptmark_core::runner::python::PythonExecutor; +use scriptmark_core::spec_loader::load_spec_str; use serde_json::{Value, json}; struct Bench { diff --git a/src/crates/scriptmark/tests/harness.rs b/src/core/tests/harness.rs similarity index 98% rename from src/crates/scriptmark/tests/harness.rs rename to src/core/tests/harness.rs index 094f9de..bb70017 100644 --- a/src/crates/scriptmark/tests/harness.rs +++ b/src/core/tests/harness.rs @@ -5,12 +5,12 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::Arc; -use scriptmark::models::Target; -use scriptmark::runner::executor::{ +use scriptmark_core::models::Target; +use scriptmark_core::runner::executor::{ CallPlan, CheckObservation, Executor, Exit, InProcessCheck, Outcome, ScriptRun, Subject, UnitObservation, UnitPlan, }; -use scriptmark::runner::python::PythonExecutor; +use scriptmark_core::runner::python::PythonExecutor; use serde_json::{Value, json}; fn write(dir: &Path, name: &str, content: &str) -> PathBuf { @@ -46,7 +46,7 @@ fn call(target: Target, args: Vec) -> CallPlan { fn unit(file: &Path) -> UnitPlan { UnitPlan { subject: Subject::Student, - functions: scriptmark::matching::Functions::default(), + functions: scriptmark_core::matching::Functions::default(), file: file.to_path_buf(), script: None, imports: Vec::new(), @@ -396,7 +396,7 @@ async fn test_inspect_reports_only_what_a_teacher_module_defines() { "helpers/teacher.py", "import csv\nfrom pathlib import Path\nfrom collections import deque\nfrom sibling import BASE\nDATA = Path(__file__).parent / 'x.csv'\n\ndef make(n, scale=BASE):\n return n * scale\n", ); - let spec: scriptmark::models::TestSpec = toml::from_str(&format!( + let spec: scriptmark_core::models::TestSpec = toml::from_str(&format!( "[meta]\nname = \"t\"\nfile = \"lab.py\"\nlanguage = \"python\"\nimports = [{:?}]\n[[cases]]\nname = \"x\"\nexpect = 1\n", teacher.to_string_lossy() )) @@ -577,7 +577,7 @@ async fn test_inspect_names_duplicates_and_the_removed_decorator() { let dir = tempfile::tempdir().unwrap(); let a = write(dir.path(), "a.py", "def helper():\n return 'a'\n"); let b = write(dir.path(), "b.py", "def helper():\n return 'b'\n"); - let spec_of = |imports: &[&PathBuf]| -> scriptmark::models::TestSpec { + let spec_of = |imports: &[&PathBuf]| -> scriptmark_core::models::TestSpec { toml::from_str(&format!( "[meta]\nname = \"t\"\nfile = \"lab.py\"\nlanguage = \"python\"\nimports = {:?}\n[[cases]]\nname = \"x\"\nexpect = 1\n", imports.iter().map(|p| p.to_string_lossy().into_owned()).collect::>() @@ -720,7 +720,7 @@ async fn test_equal_constants_in_two_teacher_modules_are_not_duplicates() { let dir = tempfile::tempdir().unwrap(); let a = write(dir.path(), "a.py", "TOL = 1e-6\nNAME = 'hello world'\n"); let b = write(dir.path(), "b.py", "TOL = 1e-6\nNAME = 'hello world'\n"); - let spec: scriptmark::models::TestSpec = toml::from_str(&format!( + let spec: scriptmark_core::models::TestSpec = toml::from_str(&format!( "[meta]\nname = \"t\"\nfile = \"lab.py\"\nlanguage = \"python\"\nimports = [{:?}, {:?}]\n[[cases]]\nname = \"x\"\nexpect = 1\n", a.to_string_lossy(), b.to_string_lossy() diff --git a/src/crates/scriptmark/tests/integration.rs b/src/core/tests/integration.rs similarity index 99% rename from src/crates/scriptmark/tests/integration.rs rename to src/core/tests/integration.rs index c10ca62..60c18b6 100644 --- a/src/crates/scriptmark/tests/integration.rs +++ b/src/core/tests/integration.rs @@ -5,13 +5,13 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use scriptmark::grading::{Policy, grade_all}; -use scriptmark::models::*; -use scriptmark::runner::frozen::Generation; -use scriptmark::runner::orchestrator::{RunOptions, run_all}; -use scriptmark::runner::prepare::prepare; -use scriptmark::runner::python::PythonExecutor; -use scriptmark::spec_loader::load_spec_str; +use scriptmark_core::grading::{Policy, grade_all}; +use scriptmark_core::models::*; +use scriptmark_core::runner::frozen::Generation; +use scriptmark_core::runner::orchestrator::{RunOptions, run_all}; +use scriptmark_core::runner::prepare::prepare; +use scriptmark_core::runner::python::PythonExecutor; +use scriptmark_core::spec_loader::load_spec_str; /// A scratch directory holding specs, teacher files and one directory per student. struct Bench { diff --git a/src/crates/scriptmark/tests/oracle.rs b/src/core/tests/oracle.rs similarity index 97% rename from src/crates/scriptmark/tests/oracle.rs rename to src/core/tests/oracle.rs index d1d1261..e882a84 100644 --- a/src/crates/scriptmark/tests/oracle.rs +++ b/src/core/tests/oracle.rs @@ -3,12 +3,12 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use scriptmark::models::{StudentReport, StudentSubmission, TestSpec, TestStatus}; -use scriptmark::runner::frozen::{Frozen, Generation}; -use scriptmark::runner::orchestrator::{RunOptions, run_all}; -use scriptmark::runner::prepare::{Bundle, prepare}; -use scriptmark::runner::python::PythonExecutor; -use scriptmark::spec_loader::load_spec_str; +use scriptmark_core::models::{StudentReport, StudentSubmission, TestSpec, TestStatus}; +use scriptmark_core::runner::frozen::{Frozen, Generation}; +use scriptmark_core::runner::orchestrator::{RunOptions, run_all}; +use scriptmark_core::runner::prepare::{Bundle, prepare}; +use scriptmark_core::runner::python::PythonExecutor; +use scriptmark_core::spec_loader::load_spec_str; use serde_json::{Value, json}; struct Bench(tempfile::TempDir); diff --git a/src/crates/scriptmark-py/Cargo.toml b/src/crates/scriptmark-py/Cargo.toml deleted file mode 100644 index 08602a5..0000000 --- a/src/crates/scriptmark-py/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "scriptmark-py" -description = "Python bindings for ScriptMark via PyO3" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -publish = false - -[lib] -name = "_scriptmark" -crate-type = ["cdylib"] - -[dependencies] -scriptmark = { path = "../scriptmark" } -pyo3 = { workspace = true } -tokio = { workspace = true } -serde_json = { workspace = true } -anyhow = { workspace = true } -toml = { workspace = true } diff --git a/src/crates/scriptmark-py/src/lib.rs b/src/crates/scriptmark-py/src/lib.rs deleted file mode 100644 index a0477c2..0000000 --- a/src/crates/scriptmark-py/src/lib.rs +++ /dev/null @@ -1,672 +0,0 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use pyo3::prelude::*; -use pyo3::types::PyDict; - -use scriptmark::assignment::Declared; -use scriptmark::discovery::{LocalInputOptions, load_local_input}; -use scriptmark::export::word; -use scriptmark::grading::Policy; -use scriptmark::models::{AssignmentInput, GradeOutcome, StudentReport, TestSpec}; -use scriptmark::record::{self, Evidence, Record, RecordError}; -use scriptmark::runner::frozen::{self, Frozen, FrozenError, Generation}; -use scriptmark::runner::generation::draws_a_seed; -use scriptmark::runner::orchestrator::{RunOptions, run_all}; -use scriptmark::runner::prepare::prepare; -use scriptmark::runner::python::PythonExecutor; -use scriptmark::spec_loader::{SpecError, load_spec as load_spec_file, load_specs_from_dir}; - -/// A test specification loaded from a TOML file. -#[pyclass(name = "TestSpec")] -#[derive(Clone)] -struct PyTestSpec { - inner: TestSpec, -} - -#[pymethods] -impl PyTestSpec { - #[getter] - fn name(&self) -> &str { - &self.inner.meta.name - } - - #[getter] - fn file(&self) -> &str { - &self.inner.meta.file - } - - #[getter] - fn function(&self) -> Option<&str> { - self.inner.meta.function.as_deref() - } - - #[getter] - fn language(&self) -> &str { - &self.inner.meta.language - } - - #[getter] - fn num_cases(&self) -> usize { - self.inner.cases.len() - } - - #[getter] - fn num_scenarios(&self) -> usize { - self.inner.scenarios.len() - } - - fn __repr__(&self) -> String { - format!( - "TestSpec(name='{}', file='{}', cases={}, scenarios={})", - self.inner.meta.name, - self.inner.meta.file, - self.inner.cases.len(), - self.inner.scenarios.len() - ) - } -} - -/// Grading results for a single student. -#[pyclass(name = "StudentResult")] -#[derive(Clone)] -struct PyStudentResult { - inner: StudentReport, -} - -#[pymethods] -impl PyStudentResult { - #[getter] - fn student_id(&self) -> &str { - &self.inner.student_id - } - - #[getter] - fn name(&self) -> Option<&str> { - self.inner.student_name.as_deref() - } - - /// The grade to publish; `None` when withheld. - #[getter] - fn grade(&self) -> Option { - self.inner.final_grade() - } - - /// "graded" or "withheld". - #[getter] - fn state(&self) -> Option<&'static str> { - self.inner.grade.as_ref().map(|g| match g.outcome { - GradeOutcome::Graded { .. } => "graded", - GradeOutcome::Withheld { .. } => "withheld", - }) - } - - /// Why the grade is withheld, or why a graded 0 is a policy 0. - #[getter] - fn reason(&self) -> Option { - self.inner - .grade - .as_ref() - .and_then(|g| g.reason()) - .map(|r| word(&r)) - } - - /// Points earned, unrounded; `None` when withheld. - #[getter] - fn score(&self) -> Option { - match self.inner.grade.as_ref()?.outcome { - GradeOutcome::Graded { score, .. } => Some(score), - GradeOutcome::Withheld { .. } => None, - } - } - - /// Points available. - #[getter] - fn max(&self) -> Option { - self.inner.grade.as_ref().map(|g| g.max) - } - - /// `score / max * scale`, before any curve. - #[getter] - fn raw_grade(&self) -> Option { - match self.inner.grade.as_ref()?.outcome { - GradeOutcome::Graded { raw_grade, .. } => Some(raw_grade), - GradeOutcome::Withheld { .. } => None, - } - } - - #[getter] - fn passed(&self) -> usize { - self.inner.total_passed() - } - - #[getter] - fn total(&self) -> usize { - self.inner.total_cases() - } - - #[getter] - fn pass_rate(&self) -> f64 { - self.inner.pass_rate() - } - - /// Return the full results as a JSON-serializable dict. - fn to_dict(&self, py: Python<'_>) -> PyResult { - let json_val = serde_json::to_value(&self.inner) - .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; - json_to_py(py, &json_val) - } - - fn __repr__(&self) -> String { - format!( - "StudentResult(id='{}', grade={}, passed={}/{})", - self.inner.student_id, - match (self.inner.final_grade(), self.reason()) { - (Some(g), _) => format!("{g}"), - (None, Some(reason)) => format!("None ({reason})"), - (None, None) => "None".to_string(), - }, - self.inner.total_passed(), - self.inner.total_cases(), - ) - } -} - -/// 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) -> PyResult>> { - 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(); - (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, roster: Option) -> PyResult { - 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 { - 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())) -} - -/// A bundle that cannot be graded is a ValueError, however it was found out; a path that -/// is not there is the OS error it always was. -fn spec_error(e: SpecError) -> PyErr { - match e { - SpecError::IoError(..) => pyo3::exceptions::PyFileNotFoundError::new_err(e.to_string()), - SpecError::NotADirectory(_) => { - pyo3::exceptions::PyNotADirectoryError::new_err(e.to_string()) - } - _ => pyo3::exceptions::PyValueError::new_err(e.to_string()), - } -} - -/// Load and validate a test specification from a TOML file. -#[pyfunction] -fn load_spec(path: String) -> PyResult { - let spec = load_spec_file(Path::new(&path)).map_err(spec_error)?; - Ok(PyTestSpec { inner: spec }) -} - -/// Frozen inputs that cannot be read: missing is the OS error, anything else a ValueError. -fn frozen_error(e: FrozenError) -> PyErr { - match &e { - FrozenError::Io(_, io) if io.kind() == std::io::ErrorKind::NotFound => { - pyo3::exceptions::PyFileNotFoundError::new_err(e.to_string()) - } - FrozenError::Io(..) => pyo3::exceptions::PyOSError::new_err(e.to_string()), - FrozenError::Invalid(..) => pyo3::exceptions::PyValueError::new_err(e.to_string()), - } -} - -/// Where generated inputs come from, and where they go. -struct Freezing<'a> { - /// Write the inputs graded on here. - freeze: Option<&'a str>, - /// Grade on the inputs frozen here instead of generating them. - replay: Option<&'a str>, -} - -impl Freezing<'_> { - /// Write the inputs graded on, once everything else has succeeded. - fn save(&self, inputs: &Frozen) -> PyResult<()> { - match self.freeze { - Some(path) => inputs - .write(Path::new(path)) - .map_err(|e| pyo3::exceptions::PyOSError::new_err(format!("{path}: {e}"))), - None => Ok(()), - } - } - - /// The file holding the inputs a batch used: the one written, else the one replayed. - fn holder(&self) -> PyResult> { - self.freeze - .or(self.replay) - .map(|p| absolute(Path::new(p))) - .transpose() - } -} - -fn value_error(e: impl std::fmt::Display) -> PyErr { - pyo3::exceptions::PyValueError::new_err(e.to_string()) -} - -/// A grading record that cannot be read: missing is the OS error, anything else a ValueError. -fn record_error(e: RecordError) -> PyErr { - match &e { - RecordError::Io(_, io) if io.kind() == std::io::ErrorKind::NotFound => { - pyo3::exceptions::PyFileNotFoundError::new_err(e.to_string()) - } - RecordError::Io(..) => pyo3::exceptions::PyOSError::new_err(e.to_string()), - RecordError::Invalid(..) => value_error(e), - } -} - -/// A path as an absolute one, as the CLI records it, so that a record written here can be -/// rescored from anywhere. -fn absolute(path: &Path) -> PyResult { - std::path::absolute(path) - .map_err(|e| pyo3::exceptions::PyOSError::new_err(format!("{}: {e}", path.display()))) -} - -/// Refuse to write a record over one holding rescored revisions, before anything runs, -/// unless `force` says to discard them. -fn check_output(output: Option<&str>, force: bool) -> PyResult<()> { - match output.map(|path| record::check_replaceable(Path::new(path))) { - Some(Err(why)) if !force => Err(value_error(format!( - "{why}: move it aside, write elsewhere, or pass force=True to discard them" - ))), - _ => Ok(()), - } -} - -fn write_record(record: &Record, output: Option<&str>) -> PyResult<()> { - match output { - Some(path) => record - .write(Path::new(path)) - .map_err(|e| pyo3::exceptions::PyOSError::new_err(format!("{path}: {e}"))), - None => Ok(()), - } -} - -/// Load the assignment and specs from a tests directory given as an absolute path. -fn declared_for( - tests: &Path, - assignment: Option<&Path>, -) -> PyResult<(Declared, Vec, Policy)> { - let mut declared = scriptmark::assignment::load(assignment, tests) - .map_err(|e| value_error(format!("{e:#}")))?; - let specs = load_specs_from_dir(tests).map_err(spec_error)?; - let policy = - scriptmark::assignment::settle(&mut declared.assignment, &declared.grading, &specs) - .map_err(|e| value_error(format!("{e:#}")))?; - declared.matching.validate(&specs).map_err(value_error)?; - Ok((declared, specs, policy)) -} - -/// Run tests for all students, returning a list of raw result dicts. -/// -/// `freeze` writes generated inputs and oracle answers; `replay` verifies and reuses -/// a frozen bundle without recomputing its answers. `output` writes the grading record, -/// unscored, for `rescore` to score; `force=True` replaces one holding rescored revisions. -#[pyfunction] -#[pyo3(signature = (submissions, tests, *, timeout=10, python="python3", assignment=None, freeze=None, replay=None, output=None, force=false))] -// Each is a keyword argument of the Python API; a struct would not be one. -#[allow(clippy::too_many_arguments)] -fn run( - submissions: Vec, - tests: String, - timeout: u64, - python: &str, - assignment: Option, - freeze: Option, - replay: Option, - output: Option, - force: bool, -) -> PyResult { - check_output(output.as_deref(), force)?; - let tests = absolute(Path::new(&tests))?; - let assignment = assignment.map(|p| absolute(Path::new(&p))).transpose()?; - let (declared, specs, _) = declared_for(&tests, assignment.as_deref())?; - let freezing = Freezing { - freeze: freeze.as_deref(), - replay: replay.as_deref(), - }; - let (record, inputs) = run_grading( - &submissions, - &tests, - specs, - timeout, - python, - &freezing, - &declared, - )?; - freezing.save(&inputs)?; - write_record(&record, output.as_deref())?; - let json_val = serde_json::to_value(&record.evidence.students).map_err(value_error)?; - Python::with_gil(|py| json_to_py(py, &json_val)) -} - -/// Grade all students: run the tests, then score each item under the assignment's -/// policy — `assignment.toml`, given or found beside the tests directory, exactly as the -/// CLI reads it. -/// -/// `freeze`, `replay` and `force` are as for `run`. `output` writes the grading record, with -/// this grade as its first revision. -/// -/// Returns a list of StudentResult objects. -#[pyfunction] -#[pyo3(signature = (submissions, tests, *, timeout=10, python="python3", assignment=None, freeze=None, replay=None, output=None, force=false))] -// Each is a keyword argument of the Python API; a struct would not be one. -#[allow(clippy::too_many_arguments)] -fn grade( - submissions: Vec, - tests: String, - timeout: u64, - python: &str, - assignment: Option, - freeze: Option, - replay: Option, - output: Option, - force: bool, -) -> PyResult> { - check_output(output.as_deref(), force)?; - let tests = absolute(Path::new(&tests))?; - let assignment = assignment.map(|p| absolute(Path::new(&p))).transpose()?; - let (declared, specs, policy) = declared_for(&tests, assignment.as_deref())?; - - let freezing = Freezing { - freeze: freeze.as_deref(), - replay: replay.as_deref(), - }; - let (mut record, inputs) = run_grading( - &submissions, - &tests, - specs, - timeout, - python, - &freezing, - &declared, - )?; - let revision = record - .score(&declared.assignment.items, &policy) - .map_err(|e| value_error(format!("{e:#}")))?; - freezing.save(&inputs)?; - write_record(&record, output.as_deref())?; - - let view = record.view(Some(revision)).map_err(value_error)?; - Ok(view - .reports - .into_iter() - .map(|r| PyStudentResult { inner: r }) - .collect()) -} - -/// Read a grading record as one of its revisions scored it: the latest unless `revision` -/// names one, and unscored evidence when nothing has scored it yet. -#[pyfunction] -#[pyo3(signature = (record, *, revision=None))] -fn load_record(record: String, revision: Option) -> PyResult> { - let record = Record::load(Path::new(&record)).map_err(record_error)?; - let view = record.view(revision).map_err(value_error)?; - Ok(view - .reports - .into_iter() - .map(|r| PyStudentResult { inner: r }) - .collect()) -} - -/// Score a grading record again under the policy as it is now, and add that as a new -/// revision. Nothing runs. The record is refused unless its assignment, submissions, -/// matching and tests are still what they were. -/// -/// `assignment` names the assignment.toml holding the policy; by default the one the -/// record was graded with, or one beside its tests directory. Returns -/// `{"revision": n, "changes": [...]}`, every student whose grade changed. -#[pyfunction] -#[pyo3(signature = (record, *, assignment=None))] -fn rescore(py: Python<'_>, record: String, assignment: Option) -> PyResult { - let path = Path::new(&record); - let mut saved = Record::load(path).map_err(record_error)?; - let inputs = saved.evidence.inputs.clone(); - let scriptmark::record::Source::Local { dirs, roster } = &inputs.source else { - return Err(value_error(format!( - "{record} was graded from a Canvas bundle: rescore it with `scriptmark rescore {record}`" - ))); - }; - let assignment = match assignment { - Some(p) => Some(absolute(Path::new(&p))?), - None => inputs.assignment.clone(), - }; - let (declared, specs, policy) = declared_for(&inputs.tests, assignment.as_deref())?; - let roster = roster - .as_deref() - .map(scriptmark::roster::load_roster) - .transpose() - .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; - let input = discover_input(dirs, &declared, roster.as_ref())?; - saved - .check(&record::Current { - assignment: &input.assignment, - attempt_policy: input.attempt_policy, - matching: &declared.matching, - specs: &specs, - students: &input.students, - }) - .map_err(|e| value_error(format!("{e:#}")))?; - let previous = saved.latest().cloned(); - let revision = saved - .score(&input.assignment.items, &policy) - .map_err(|e| value_error(format!("{e:#}")))?; - let changes = record::diff( - previous.as_ref(), - saved.revision(revision).map_err(value_error)?, - ); - saved - .write(path) - .map_err(|e| pyo3::exceptions::PyOSError::new_err(format!("{record}: {e}")))?; - let json_val = serde_json::json!({ "revision": revision, "changes": changes }); - json_to_py(py, &json_val) -} - -/// The local input, as grading finds it, refusing one with errors. -fn discover_input( - dirs: &[PathBuf], - declared: &Declared, - roster: Option<&scriptmark::roster::Roster>, -) -> PyResult { - let paths: Vec<&Path> = dirs.iter().map(PathBuf::as_path).collect(); - let input = load_local_input( - &paths, - LocalInputOptions { - assignment: declared.assignment.clone(), - roster, - attempt_policy: declared.attempt_policy, - matching: Some(&declared.matching), - }, - ) - .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; - let errors: Vec = input.errors().map(ToString::to_string).collect(); - if !errors.is_empty() { - return Err(value_error(errors.join("; "))); - } - Ok(input) -} - -/// Shared logic: discover submissions, run the specs through the orchestrator, and record -/// what they found, unscored. Returns the record and the inputs it was graded on, for -/// `Freezing::save`. -/// -/// A seed drawn with nowhere to keep it could never be replayed, and a `freeze` path that -/// cannot be written would lose the inputs after the whole class ran: both are refused -/// before any student runs. -fn run_grading( - submissions: &[String], - tests: &Path, - specs: Vec, - timeout: u64, - python: &str, - freezing: &Freezing, - declared: &Declared, -) -> PyResult<(Record, Frozen)> { - let generation = match freezing.replay { - Some(path) => Generation::Replay(Frozen::load(Path::new(path)).map_err(frozen_error)?), - None => Generation::fresh(), - }; - if freezing.replay.is_none() - && freezing.freeze.is_none() - && let Some((spec, case)) = draws_a_seed(&specs) - { - return Err(value_error(format!( - "case '{case}' in '{spec}' draws a random seed: pass freeze='cases.json' to keep it, or declare seed = N" - ))); - } - if let Some(path) = freezing.freeze { - frozen::writable(Path::new(path)) - .map_err(|e| pyo3::exceptions::PyOSError::new_err(format!("{path}: {e}")))?; - } - let dirs = submissions - .iter() - .map(|p| absolute(Path::new(p))) - .collect::>>()?; - let input = discover_input(&dirs, declared, None)?; - let spec_versions = record::spec_versions(&specs).map_err(|e| value_error(format!("{e:#}")))?; - let versions = record::submission_versions(&input.students) - .map_err(|e| pyo3::exceptions::PyOSError::new_err(format!("{e:#}")))?; - - let executor = Arc::new(PythonExecutor::with_python_cmd(python)); - let options = RunOptions { - matching: declared.matching.clone(), - concurrency: None, - python: executor.python_cmd().to_string(), - }; - - // Bridge sync PyO3 → async tokio - let rt = tokio::runtime::Runtime::new() - .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; - - let (mut reports, inputs) = rt.block_on(async { - let bundles = prepare(specs, &generation, executor.clone(), timeout) - .await - .map_err(value_error)?; - let inputs = Frozen::of(&bundles); - let reports = run_all(&input.students, bundles.into(), executor, &options).await; - Ok::<_, PyErr>((reports, inputs)) - })?; - record::seal(&mut reports, &input.students, versions) - .map_err(|e| value_error(format!("{e:#}")))?; - let bundle = record::bundle_version( - spec_versions, - timeout, - options.python.clone(), - &inputs, - freezing.holder()?, - ); - let record = Record::new(Evidence { - scriptmark: env!("CARGO_PKG_VERSION").to_string(), - assignment: (&input.assignment).into(), - inputs: record::Inputs { - tests: tests.to_path_buf(), - assignment: declared.path.as_deref().map(absolute).transpose()?, - source: record::Source::Local { dirs, roster: None }, - }, - attempt_policy: input.attempt_policy, - matching: declared.matching.clone(), - bundle, - students: reports, - }) - .map_err(|e| value_error(format!("{e:#}")))?; - Ok((record, inputs)) -} - -/// Convert serde_json::Value to a Python object. -fn json_to_py(py: Python<'_>, val: &serde_json::Value) -> PyResult { - match val { - serde_json::Value::Null => Ok(py.None()), - serde_json::Value::Bool(b) => Ok(b.into_pyobject(py)?.to_owned().into_any().unbind()), - serde_json::Value::Number(n) => { - if let Some(i) = n.as_i64() { - Ok(i.into_pyobject(py)?.into_any().unbind()) - } else if let Some(f) = n.as_f64() { - Ok(f.into_pyobject(py)?.into_any().unbind()) - } else { - Ok(py.None()) - } - } - serde_json::Value::String(s) => Ok(s.into_pyobject(py)?.into_any().unbind()), - serde_json::Value::Array(arr) => { - let items: Vec = arr - .iter() - .map(|v| json_to_py(py, v)) - .collect::>()?; - Ok(items.into_pyobject(py)?.into_any().unbind()) - } - serde_json::Value::Object(map) => { - let dict = PyDict::new(py); - for (k, v) in map { - dict.set_item(k, json_to_py(py, v)?)?; - } - Ok(dict.into_any().unbind()) - } - } -} - -#[pymodule] -fn _scriptmark(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_class::()?; - m.add_class::()?; - 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)?)?; - m.add_function(wrap_pyfunction!(load_record, m)?)?; - m.add_function(wrap_pyfunction!(rescore, m)?)?; - Ok(()) -} diff --git a/src/python/scriptmark/__init__.py b/src/python/scriptmark/__init__.py deleted file mode 100644 index a188e0d..0000000 --- a/src/python/scriptmark/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -"""ScriptMark — automated grading for student programming assignments.""" - -from scriptmark._scriptmark import ( - discover, - grade, - load_input, - load_record, - load_spec, - rescore, - run, - StudentResult, - TestSpec, -) - -__all__ = [ - "discover", - "grade", - "load_input", - "load_record", - "load_spec", - "rescore", - "run", - "StudentResult", - "TestSpec", -]