Skip to content

Commit 2830b91

Browse files
committed
Merge cli
2 parents 05cf007 + 967585c commit 2830b91

17 files changed

+6868
-0
lines changed

cli/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
dist/
2+
build/
3+
# pyret output, must be .cjs instead of .jarr because of "type": "module"
4+
src/*.cjs
5+

cli/.npmignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
node_modules/
2+
.direnv/
3+
.pyret/
4+
5+
build/_pyret-work
6+
# don't ignore generated...

cli/.swcrc

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
2+
"$schema": "https://swc.rs/schema.json",
3+
"jsc": {
4+
"parser": {
5+
"syntax": "typescript"
6+
},
7+
"target": "esnext",
8+
"experimental": {
9+
"keepImportAttributes": true
10+
}
11+
}
12+
}

cli/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# pyret-autograder-cli
2+
3+
A command-line interface for running pyret-autograders locally.
4+
5+
## Usage
6+
7+
Npm will automatically download the latest version of this package if you run
8+
9+
```
10+
npx pyret-autograder-cli --help
11+
```
12+
13+
The package also exposes an executable under the name `pyret-autograder`

cli/bin/index.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/usr/bin/env node
2+
/*
3+
Copyright (C) 2025 ironmoon <me@ironmoon.dev>
4+
5+
This file is part of pyret-autograder-cli.
6+
7+
pyret-autograder-cli is free software: you can redistribute it and/or modify
8+
it under the terms of the GNU Affero General Public License as published by
9+
the Free Software Foundation, either version 3 of the License, or (at your
10+
option) any later version.
11+
12+
pyret-autograder-cli is distributed in the hope that it will be useful, but
13+
WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
14+
FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
15+
for more details.
16+
17+
You should have received a copy of the GNU Affero General Public License
18+
with pyret-autograder-cli. If not, see <http://www.gnu.org/licenses/>.
19+
*/
20+
21+
import { program } from "commander";
22+
import Package from "../package.json" with { type: "json" };
23+
import { pawtograderAction } from "./pawtograder.js";
24+
25+
program
26+
.name("pyret-autograder-cli")
27+
.description(
28+
"Run Pyret autograders using the same configuration expected by an adaptor.",
29+
)
30+
.version(Package.version);
31+
32+
program
33+
.command("pawtograder")
34+
.description("Run the autograder on a Pawtograder specification.")
35+
.argument("<submission>", "The submission directory to use.")
36+
.option(
37+
"-s, --solution <dir>",
38+
"The directory containing the solution and autograder specification.",
39+
".",
40+
)
41+
.action(pawtograderAction);
42+
43+
program.addHelpText(
44+
"afterAll",
45+
`
46+
License:
47+
pyret-autograder-cli is licensed under the GNU Affero General Public License v3.0 or later.
48+
See <https://www.gnu.org/licenses/> for more information.`,
49+
);
50+
51+
program.parse();

cli/bin/pawtograder.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/*
2+
Copyright (C) 2025 ironmoon <me@ironmoon.dev>
3+
4+
This file is part of pyret-autograder-cli.
5+
6+
pyret-autograder-cli is free software: you can redistribute it and/or modify
7+
it under the terms of the GNU Affero General Public License as published by
8+
the Free Software Foundation, either version 3 of the License, or (at your
9+
option) any later version.
10+
11+
pyret-autograder-cli is distributed in the hope that it will be useful, but
12+
WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13+
FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
14+
for more details.
15+
16+
You should have received a copy of the GNU Affero General Public License
17+
with pyret-autograder-cli. If not, see <http://www.gnu.org/licenses/>.
18+
*/
19+
20+
import yaml from "yaml";
21+
import { readFile, mkdtemp } from "node:fs/promises";
22+
import { spawn } from "node:child_process";
23+
import path from "node:path";
24+
import { Config, Spec, z } from "pyret-autograder-pawtograder";
25+
import chalk from "chalk";
26+
import os from "os";
27+
28+
const PKG_ROOT = path.resolve(import.meta.dirname, "..");
29+
const DEFAULT_COMPILED_PATH =
30+
path.join(PKG_ROOT, "build/pyret/lib-compiled") +
31+
":" +
32+
path.join(PKG_ROOT, "build/pyret/cpo-compiled");
33+
34+
async function resolveSpec(submissionPath: string, solutionPath: string) {
35+
const rawConfig = await readFile(
36+
path.join(solutionPath, "pawtograder.yml"),
37+
"utf8",
38+
);
39+
40+
const config: Config = yaml.parse(rawConfig, { merge: true });
41+
42+
console.dir(config, { depth: 3 });
43+
44+
const parseRes = Spec.safeParse({
45+
solution_dir: solutionPath,
46+
submission_dir: submissionPath,
47+
config,
48+
});
49+
50+
if (parseRes.success) {
51+
return parseRes.data;
52+
} else {
53+
const pretty = z.prettifyError(parseRes.error);
54+
const err =
55+
chalk.redBright.bold`Invalid specification provided` +
56+
`:\n${chalk.yellow(pretty)}\n\n` +
57+
chalk.bold`See the ` +
58+
chalk.blackBright.bold`cause` +
59+
chalk.bold` field for the full error.`;
60+
61+
throw new Error(err, { cause: parseRes.error });
62+
}
63+
}
64+
65+
export async function pawtograderAction(
66+
submission: string,
67+
{ solution }: { solution: string },
68+
) {
69+
const submissionPath = path.resolve(submission);
70+
const solutionPath = path.resolve(solution);
71+
const spec = await resolveSpec(submissionPath, solutionPath);
72+
73+
console.log(
74+
`Grading submission at ${submissionPath} with the specification located in ${solutionPath}`,
75+
);
76+
77+
const artifactDir = await (async () => {
78+
if (process.env.PA_ARTIFACT_DIR != null) return process.env.PA_ARTIFACT_DIR;
79+
const prefix = path.join(os.tmpdir(), "pyret-autograder-");
80+
return await mkdtemp(prefix);
81+
})();
82+
const result = await new Promise((resolve, reject) => {
83+
const env = {
84+
PA_PYRET_LANG_COMPILED_PATH: DEFAULT_COMPILED_PATH,
85+
PA_CURRENT_LOAD_PATH: submissionPath,
86+
PA_ARTIFACT_DIR: artifactDir,
87+
...process.env,
88+
PWD: submissionPath,
89+
};
90+
91+
const child = spawn(
92+
process.execPath,
93+
[path.join(import.meta.dirname, "../src/pawtograder.cjs")],
94+
{
95+
env,
96+
cwd: submissionPath,
97+
// [ stdin, stdout, stderr, custom]
98+
stdio: ["pipe", "pipe", "pipe", "pipe"],
99+
},
100+
);
101+
102+
console.log("grader started");
103+
104+
for (const [stream, target, name] of [
105+
[child.stdout, process.stdout, chalk.blue`stdout`],
106+
[child.stderr, process.stderr, chalk.red`stderr`],
107+
] as const) {
108+
const prefix = `${name} » `;
109+
let leftover = "";
110+
stream.setEncoding("utf8");
111+
stream.on("data", (chunk) => {
112+
const lines = (leftover + chunk).split(/\n/);
113+
leftover = lines.pop()!;
114+
for (const line of lines) target.write(`${prefix}${line}\n`);
115+
});
116+
stream.on("end", () => {
117+
if (leftover) target.write(`${prefix}${leftover}\n`);
118+
});
119+
}
120+
121+
const fd3 = child.stdio[3] as NodeJS.ReadableStream;
122+
let output = "";
123+
fd3.setEncoding("utf8");
124+
fd3.on("data", (chunk: string) => (output += chunk));
125+
126+
child.on("close", (code) => {
127+
console.log("grader ended");
128+
if (code !== 0) {
129+
return reject(new Error(`Grader failed with code ${code}.`));
130+
}
131+
try {
132+
resolve(JSON.parse(output));
133+
} catch (e) {
134+
reject(new Error(`Invalid JSON from grader: ${output}\n${e}`));
135+
}
136+
});
137+
138+
child.stdin.write(JSON.stringify(spec));
139+
child.stdin.end();
140+
});
141+
142+
console.dir(result);
143+
console.log(`Artifact Dir: ${artifactDir}`);
144+
}

cli/flake.lock

Lines changed: 64 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/flake.nix

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
inputs = {
3+
nixpkgs.url = "github:NixOS/nixpkgs/release-25.05";
4+
systems.url = "github:nix-systems/default";
5+
treefmt-nix.url = "github:numtide/treefmt-nix";
6+
treefmt-nix.inputs.nixpkgs.follows = "nixpkgs";
7+
};
8+
9+
outputs =
10+
{
11+
nixpkgs,
12+
systems,
13+
treefmt-nix,
14+
...
15+
}:
16+
let
17+
eachSystem = f: nixpkgs.lib.genAttrs (import systems) (s: f (import nixpkgs { system = s; }));
18+
treefmtEval = eachSystem (pkgs: treefmt-nix.lib.evalModule pkgs ./nix/treefmt.nix);
19+
in
20+
{
21+
formatter = eachSystem (pkgs: treefmtEval.${pkgs.system}.config.build.wrapper);
22+
23+
devShells = eachSystem (pkgs: {
24+
default = pkgs.mkShell {
25+
packages = with pkgs; [
26+
nodejs_24
27+
gnumake
28+
coreutils-full
29+
];
30+
shellHook = ''
31+
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath [ pkgs.libuuid ]}:''$LD_LIBRARY_PATH"
32+
'';
33+
};
34+
});
35+
};
36+
}

cli/nix/treefmt.nix

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{ ... }:
2+
{
3+
projectRootFile = "flake.nix";
4+
settings.global.excludes = [
5+
"COPYING"
6+
".envrc"
7+
"**/.gitignore"
8+
];
9+
programs = {
10+
deadnix.enable = true;
11+
nixfmt.enable = true;
12+
mdformat.enable = true;
13+
prettier.enable = true;
14+
};
15+
settings.formatter = {
16+
prettier.options = [
17+
"--config"
18+
(toString ../.prettierrc.json)
19+
];
20+
};
21+
}

0 commit comments

Comments
 (0)