pll-python 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +45 -1
  2. package/cli.cjs +310 -55
  3. package/package.json +1 -1
  4. package/worker.cjs +17 -12
package/cli.cjs CHANGED
@@ -24,8 +24,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/cli/main.ts
27
- var fs8 = __toESM(require("node:fs"));
28
- var path8 = __toESM(require("node:path"));
27
+ var fs9 = __toESM(require("node:fs"));
28
+ var path9 = __toESM(require("node:path"));
29
29
 
30
30
  // src/cli/args.ts
31
31
  var import_node_util = require("node:util");
@@ -93,7 +93,7 @@ function reworded(err, options, argv) {
93
93
  return message;
94
94
  }
95
95
 
96
- // src/cli/examplar.ts
96
+ // src/cli/bundle.ts
97
97
  var fs4 = __toESM(require("node:fs/promises"));
98
98
  var path4 = __toESM(require("node:path"));
99
99
 
@@ -396,7 +396,8 @@ function validateBundleUrl(url) {
396
396
  }
397
397
  return `\`${url}\` must be an https address.`;
398
398
  }
399
- async function loadBundle(url, store, fetchImpl = globalThis.fetch) {
399
+ var EXAMPLAR_BUNDLE = { noun: "an Examplar bundle", looksRight: looksLikeBundle };
400
+ async function loadBundle(url, store, fetchImpl = globalThis.fetch, kind = EXAMPLAR_BUNDLE) {
400
401
  const cached = await store.read(url).catch(() => void 0);
401
402
  let response;
402
403
  try {
@@ -424,8 +425,8 @@ async function loadBundle(url, store, fetchImpl = globalThis.fetch) {
424
425
  error: `${url} is ${json.length} bytes, past the ${MAX_BUNDLE_BYTES}-byte limit`
425
426
  };
426
427
  }
427
- if (!looksLikeBundle(json)) {
428
- return cached ? { json: cached.json, fromCache: true, note: `${url} did not return a bundle; using the cached copy` } : { fromCache: false, error: `${url} did not return an Examplar bundle` };
428
+ if (!kind.looksRight(json)) {
429
+ return cached ? { json: cached.json, fromCache: true, note: `${url} did not return a bundle; using the cached copy` } : { fromCache: false, error: `${url} did not return ${kind.noun}` };
429
430
  }
430
431
  const etag = response.headers.get("etag") ?? void 0;
431
432
  try {
@@ -682,8 +683,8 @@ var WorkerConnection = class {
682
683
  return Promise.reject(new Error(NOT_RUNNING));
683
684
  }
684
685
  const id = this.nextId++;
685
- return new Promise((resolve4, reject) => {
686
- this.pending.set(id, { resolve: resolve4, reject, live });
686
+ return new Promise((resolve5, reject) => {
687
+ this.pending.set(id, { resolve: resolve5, reject, live });
687
688
  worker.post({ id, type, ...fields });
688
689
  });
689
690
  }
@@ -799,7 +800,7 @@ function concatBytes(a, b) {
799
800
  }
800
801
 
801
802
  // src/run/examplar.ts
802
- async function runExamplarStep(runtime, bundles, host, code, fileName) {
803
+ async function runExamplarStep(runtime, bundles, host, code, fileName, sessionKey) {
803
804
  const bundle = await fetchBundle(bundles, host, code);
804
805
  if (bundle === null) {
805
806
  return null;
@@ -827,7 +828,7 @@ async function runExamplarStep(runtime, bundles, host, code, fileName) {
827
828
  }
828
829
  let result;
829
830
  try {
830
- result = await runtime.examplarRun(code, bundle.json, fileName);
831
+ result = await runtime.examplarRun(code, bundle.json, fileName, sessionKey);
831
832
  } catch (err) {
832
833
  if (!(err instanceof StoppedError)) {
833
834
  host.examplarCard(failedCard(bundle, errorText(err)));
@@ -1003,6 +1004,128 @@ function timedOutProblem(stuck) {
1003
1004
  return `\`${stuck.test}\` ran for more than ${stuck.seconds} seconds against ${against}, so the check stopped. ` + (stuck.kind === "wheat" ? "A test that runs that long is usually stuck in a loop." : "That implementation may never finish on what this test gives it; tell your course staff.");
1004
1005
  }
1005
1006
 
1007
+ // src/run/bundles.ts
1008
+ var DIRECTIVE_RE2 = /^#\s*bundle\s+(\S+)\s+(\S+:\/\/\S*)\s*$/;
1009
+ var NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
1010
+ var KEYWORDS = /* @__PURE__ */ new Set([
1011
+ "False",
1012
+ "None",
1013
+ "True",
1014
+ "and",
1015
+ "as",
1016
+ "assert",
1017
+ "async",
1018
+ "await",
1019
+ "break",
1020
+ "class",
1021
+ "continue",
1022
+ "def",
1023
+ "del",
1024
+ "elif",
1025
+ "else",
1026
+ "except",
1027
+ "finally",
1028
+ "for",
1029
+ "from",
1030
+ "global",
1031
+ "if",
1032
+ "import",
1033
+ "in",
1034
+ "is",
1035
+ "lambda",
1036
+ "nonlocal",
1037
+ "not",
1038
+ "or",
1039
+ "pass",
1040
+ "raise",
1041
+ "return",
1042
+ "try",
1043
+ "while",
1044
+ "with",
1045
+ "yield"
1046
+ ]);
1047
+ var MODULE_BUNDLE = {
1048
+ noun: "a bundle",
1049
+ looksRight(json) {
1050
+ try {
1051
+ const bundle = JSON.parse(json);
1052
+ return typeof bundle === "object" && bundle !== null && typeof bundle.bundle === "number" && typeof bundle.pyc === "string";
1053
+ } catch {
1054
+ return false;
1055
+ }
1056
+ }
1057
+ };
1058
+ function parseBundleDirectives(source) {
1059
+ const bundles = [];
1060
+ const problems = [];
1061
+ const lines = source.split(/\r?\n/);
1062
+ for (let i = 0; i < lines.length; i++) {
1063
+ const match = DIRECTIVE_RE2.exec(lines[i].trim());
1064
+ if (match === null) {
1065
+ continue;
1066
+ }
1067
+ const [, name, url] = match;
1068
+ const line = i + 1;
1069
+ if (!NAME_RE.test(name) || KEYWORDS.has(name)) {
1070
+ problems.push({
1071
+ line,
1072
+ message: `\`${name}\` can't name a bundle: a name is letters, digits and \`_\`, not starting with a digit, and not one of Python's keywords.`
1073
+ });
1074
+ continue;
1075
+ }
1076
+ const earlier = bundles.find((bundle) => bundle.name === name);
1077
+ if (earlier !== void 0) {
1078
+ problems.push({
1079
+ line,
1080
+ message: `line ${earlier.line} already names a bundle \`${name}\`. Give each bundle its own name.`
1081
+ });
1082
+ continue;
1083
+ }
1084
+ const problem = validateBundleUrl(url);
1085
+ if (problem !== null) {
1086
+ problems.push({ line, message: problem });
1087
+ continue;
1088
+ }
1089
+ bundles.push({ name, url, line });
1090
+ }
1091
+ return { bundles, problems };
1092
+ }
1093
+ async function runBundleStep(runtime, store, host, code, sessionKey) {
1094
+ const { bundles, problems } = parseBundleDirectives(code);
1095
+ if (problems.length > 0) {
1096
+ for (const problem of problems) {
1097
+ host.say(`Line ${problem.line}: ${problem.message}`, "problem");
1098
+ }
1099
+ return "failed";
1100
+ }
1101
+ if (bundles.length === 0) {
1102
+ return "none";
1103
+ }
1104
+ host.status("Loading bundles...");
1105
+ const entries = [];
1106
+ for (const bundle of bundles) {
1107
+ const load = await loadBundle(bundle.url, store, void 0, MODULE_BUNDLE);
1108
+ if (load.note) {
1109
+ host.say(`The bundle \`${bundle.name}\`: ${load.note}`, "note");
1110
+ }
1111
+ if (load.json === void 0) {
1112
+ host.say(`Line ${bundle.line}: could not load the bundle \`${bundle.name}\`: ${load.error}.`, "problem");
1113
+ return "failed";
1114
+ }
1115
+ entries.push({ name: bundle.name, json: load.json });
1116
+ }
1117
+ const result = await runtime.bundleLoad(sessionKey, JSON.stringify(entries));
1118
+ if (!result.ok) {
1119
+ const at = bundles.find((bundle) => bundle.name === result.name);
1120
+ host.say(
1121
+ `Line ${at?.line ?? "?"}: the bundle \`${result.name ?? "?"}\` could not be loaded: ${result.error ?? "it is broken"}.`,
1122
+ "problem"
1123
+ );
1124
+ return "failed";
1125
+ }
1126
+ return "loaded";
1127
+ }
1128
+
1006
1129
  // src/run/packages.ts
1007
1130
  var MAY_NEED_PACKAGES_RE = /\bimport\b|__import__|\.\s*to_pandas\s*\(/;
1008
1131
  function needsPackages(code, siblings = []) {
@@ -1244,7 +1367,14 @@ async function runFilePlan(runtime, host, plan) {
1244
1367
  if (stopped(host, STOPPED.beforeStart)) {
1245
1368
  return "stopped";
1246
1369
  }
1247
- const complete = await runExamplarStep(runtime, plan.bundles, host, code, fileName);
1370
+ if (await runBundleStep(runtime, plan.bundles, host, code, sessionKey) === "failed") {
1371
+ host.say("The file was not run.", "problem");
1372
+ return "blocked";
1373
+ }
1374
+ if (stopped(host, STOPPED.beforeStart)) {
1375
+ return "stopped";
1376
+ }
1377
+ const complete = await runExamplarStep(runtime, plan.bundles, host, code, fileName, sessionKey);
1248
1378
  if (complete !== null && stopped(host, STOPPED.checking)) {
1249
1379
  return "stopped";
1250
1380
  }
@@ -1635,8 +1765,8 @@ function processStatus(code) {
1635
1765
  }
1636
1766
  async function startPython(runtime, stopRequested) {
1637
1767
  let watch;
1638
- const stop = new Promise((resolve4) => {
1639
- watch = setInterval(() => stopRequested() && resolve4("stopped"), 50);
1768
+ const stop = new Promise((resolve5) => {
1769
+ watch = setInterval(() => stopRequested() && resolve5("stopped"), 50);
1640
1770
  });
1641
1771
  try {
1642
1772
  return await Promise.race([
@@ -1711,7 +1841,105 @@ function cannotRead(file, err) {
1711
1841
  }
1712
1842
  }
1713
1843
 
1844
+ // src/cli/bundle.ts
1845
+ var BUNDLE_USAGE = `pll bundle - author bundles
1846
+
1847
+ pll bundle build <file.py> [-o bundle.json]
1848
+
1849
+ A bundle is a module a program names with a line of its own,
1850
+
1851
+ #bundle <name> https://example.edu/<file>.json
1852
+
1853
+ and then uses by that name, with no import: functions to call whose code
1854
+ the student doesn't see. Put the bundle where that address finds it.
1855
+
1856
+ <file.py> is the module, with the \`#level\` line it was written at: its code
1857
+ starts with that level's names (\`dataclass\`, \`Callable\`) wherever it is
1858
+ used. Its top-level \`test_\` functions, and an \`import pytest\`, are left out
1859
+ of the bundle; everything else is in it, docstrings too.
1860
+
1861
+ Only bytecode is written to the bundle, so the source stays in your
1862
+ repository. It is compiled by the Pyodide this package pins, so it always
1863
+ matches the interpreter that will run it.
1864
+
1865
+ -o, --out FILE write here instead of stdout
1866
+ `;
1867
+ var BUNDLE_USAGE_HINT = "Usage: pll bundle build <file.py> [-o bundle.json]. `pll bundle --help` says more.";
1868
+ var BUILD_OPTIONS = {
1869
+ out: { type: "string", short: "o", needs: "a file" },
1870
+ help: { type: "boolean", short: "h" }
1871
+ };
1872
+ function parseBundleArgs(argv) {
1873
+ if (argv[0] === "-h" || argv[0] === "--help") {
1874
+ return { help: true };
1875
+ }
1876
+ if (argv[0] !== "build") {
1877
+ return {
1878
+ help: false,
1879
+ error: argv.length === 0 ? "bundle needs a subcommand" : `unknown bundle subcommand ${argv[0]}`
1880
+ };
1881
+ }
1882
+ const line = parseCommandLine(argv.slice(1), BUILD_OPTIONS);
1883
+ if ("error" in line) {
1884
+ return { help: false, error: line.error };
1885
+ }
1886
+ const { values, positionals } = line;
1887
+ const args = {
1888
+ file: positionals[0],
1889
+ out: values.out,
1890
+ help: values.help === true
1891
+ };
1892
+ if (positionals.length > 1) {
1893
+ args.error = `unexpected extra argument ${positionals[1]}`;
1894
+ } else if (!args.help && args.file === void 0) {
1895
+ args.error = "no file given";
1896
+ } else if (args.file !== void 0 && !args.file.endsWith(".py")) {
1897
+ args.error = `${args.file} is not a .py file`;
1898
+ }
1899
+ return args;
1900
+ }
1901
+ async function runBundle(runtime, view, argv) {
1902
+ const args = parseBundleArgs(argv);
1903
+ if (args.help) {
1904
+ process.stdout.write(BUNDLE_USAGE);
1905
+ return EXIT.ok;
1906
+ }
1907
+ if (args.error !== void 0) {
1908
+ process.stderr.write(`pll: ${args.error}
1909
+ ${BUNDLE_USAGE_HINT}
1910
+ `);
1911
+ return EXIT.usage;
1912
+ }
1913
+ const file = path4.resolve(args.file);
1914
+ let source;
1915
+ try {
1916
+ source = await fs4.readFile(file, "utf8");
1917
+ } catch (err) {
1918
+ view.problem(`pll: cannot read ${args.file}: ${errorText(err)}`);
1919
+ return EXIT.usage;
1920
+ }
1921
+ const built = await runtime.bundleBuild(source, path4.basename(file));
1922
+ if (!built.ok || !built.bundle) {
1923
+ view.problem(`pll: ${built.error ?? "could not build the bundle"}`);
1924
+ return EXIT.usage;
1925
+ }
1926
+ const bundle = built.bundle;
1927
+ view.note(
1928
+ `built for Python ${bundle.built.python}, at #level ${bundle.level}: defines ${bundle.defines.join(", ") || "nothing"}`
1929
+ );
1930
+ const json = JSON.stringify(bundle);
1931
+ if (args.out === void 0) {
1932
+ process.stdout.write(json + "\n");
1933
+ } else {
1934
+ await fs4.writeFile(path4.resolve(args.out), json + "\n", "utf8");
1935
+ view.note(`wrote ${args.out} (${json.length} bytes)`);
1936
+ }
1937
+ return EXIT.ok;
1938
+ }
1939
+
1714
1940
  // src/cli/examplar.ts
1941
+ var fs5 = __toESM(require("node:fs/promises"));
1942
+ var path5 = __toESM(require("node:path"));
1715
1943
  var EXAMPLAR_USAGE = `pll examplar - author Examplar bundles
1716
1944
 
1717
1945
  pll examplar build <dir> [-o bundle.json] [--verify tests.py]
@@ -1743,7 +1971,7 @@ bundles with this tool rather than a local python.
1743
1971
  wrong fails on everything.
1744
1972
  `;
1745
1973
  var EXAMPLAR_USAGE_HINT = "Usage: pll examplar build <dir> [-o bundle.json] [--verify tests.py]. `pll examplar --help` says more.";
1746
- var BUILD_OPTIONS = {
1974
+ var BUILD_OPTIONS2 = {
1747
1975
  out: { type: "string", short: "o", needs: "a file" },
1748
1976
  verify: { type: "string", needs: "a test file" },
1749
1977
  help: { type: "boolean", short: "h" }
@@ -1758,7 +1986,7 @@ function parseExamplarArgs(argv) {
1758
1986
  error: argv.length === 0 ? "examplar needs a subcommand" : `unknown examplar subcommand ${argv[0]}`
1759
1987
  };
1760
1988
  }
1761
- const line = parseCommandLine(argv.slice(1), BUILD_OPTIONS);
1989
+ const line = parseCommandLine(argv.slice(1), BUILD_OPTIONS2);
1762
1990
  if ("error" in line) {
1763
1991
  return { help: false, error: line.error };
1764
1992
  }
@@ -1778,17 +2006,17 @@ function parseExamplarArgs(argv) {
1778
2006
  }
1779
2007
  async function readDir(folder) {
1780
2008
  try {
1781
- return await fs4.readdir(folder, { withFileTypes: true });
2009
+ return await fs5.readdir(folder, { withFileTypes: true });
1782
2010
  } catch {
1783
2011
  throw new Error(`${folder} does not exist. See \`pll examplar --help\`.`);
1784
2012
  }
1785
2013
  }
1786
2014
  async function readWheats(dir) {
1787
- const folder = path4.join(dir, "wheats");
2015
+ const folder = path5.join(dir, "wheats");
1788
2016
  const out = {};
1789
2017
  for (const entry of await readDir(folder)) {
1790
2018
  if (!entry.isFile() || !entry.name.endsWith(".py")) continue;
1791
- out[entry.name.slice(0, -3)] = await fs4.readFile(path4.join(folder, entry.name), "utf8");
2019
+ out[entry.name.slice(0, -3)] = await fs5.readFile(path5.join(folder, entry.name), "utf8");
1792
2020
  }
1793
2021
  if (Object.keys(out).length === 0) {
1794
2022
  throw new Error(`${folder} has no .py files in it.`);
@@ -1796,7 +2024,7 @@ async function readWheats(dir) {
1796
2024
  return out;
1797
2025
  }
1798
2026
  async function readChaffs(dir) {
1799
- const folder = path4.join(dir, "chaffs");
2027
+ const folder = path5.join(dir, "chaffs");
1800
2028
  const out = {};
1801
2029
  const loose = [];
1802
2030
  for (const entry of await readDir(folder)) {
@@ -1805,11 +2033,11 @@ async function readChaffs(dir) {
1805
2033
  continue;
1806
2034
  }
1807
2035
  if (!entry.isDirectory()) continue;
1808
- const inner = path4.join(folder, entry.name);
2036
+ const inner = path5.join(folder, entry.name);
1809
2037
  const group = {};
1810
- for (const file of await fs4.readdir(inner, { withFileTypes: true })) {
2038
+ for (const file of await fs5.readdir(inner, { withFileTypes: true })) {
1811
2039
  if (!file.isFile() || !file.name.endsWith(".py")) continue;
1812
- group[file.name.slice(0, -3)] = await fs4.readFile(path4.join(inner, file.name), "utf8");
2040
+ group[file.name.slice(0, -3)] = await fs5.readFile(path5.join(inner, file.name), "utf8");
1813
2041
  }
1814
2042
  if (Object.keys(group).length > 0) {
1815
2043
  out[entry.name] = group;
@@ -1817,7 +2045,7 @@ async function readChaffs(dir) {
1817
2045
  }
1818
2046
  if (loose.length > 0) {
1819
2047
  throw new Error(
1820
- `${path4.join(folder, loose[0])} is not inside a function's directory. Chaffs go in \`chaffs/<function>/\`, named after the function they break - move it to \`chaffs/<function>/${loose[0]}\`.`
2048
+ `${path5.join(folder, loose[0])} is not inside a function's directory. Chaffs go in \`chaffs/<function>/\`, named after the function they break - move it to \`chaffs/<function>/${loose[0]}\`.`
1821
2049
  );
1822
2050
  }
1823
2051
  if (Object.keys(out).length === 0) {
@@ -1837,7 +2065,7 @@ ${EXAMPLAR_USAGE_HINT}
1837
2065
  `);
1838
2066
  return EXIT.usage;
1839
2067
  }
1840
- const dir = path4.resolve(args.dir);
2068
+ const dir = path5.resolve(args.dir);
1841
2069
  let sources;
1842
2070
  try {
1843
2071
  sources = { wheats: await readWheats(dir), chaffs: await readChaffs(dir) };
@@ -1864,7 +2092,7 @@ ${EXAMPLAR_USAGE_HINT}
1864
2092
  if (args.out === void 0) {
1865
2093
  process.stdout.write(json + "\n");
1866
2094
  } else {
1867
- await fs4.writeFile(path4.resolve(args.out), json + "\n", "utf8");
2095
+ await fs5.writeFile(path5.resolve(args.out), json + "\n", "utf8");
1868
2096
  view.note(`wrote ${args.out} (${json.length} bytes)`);
1869
2097
  }
1870
2098
  return EXIT.ok;
@@ -1875,7 +2103,7 @@ function caught(impl) {
1875
2103
  async function verify(runtime, view, bundle, testsPath) {
1876
2104
  let testSource;
1877
2105
  try {
1878
- testSource = await fs4.readFile(path4.resolve(testsPath), "utf8");
2106
+ testSource = await fs5.readFile(path5.resolve(testsPath), "utf8");
1879
2107
  } catch (err) {
1880
2108
  view.problem(`pll: cannot read ${testsPath}: ${errorText(err)}`);
1881
2109
  return EXIT.usage;
@@ -1893,14 +2121,14 @@ async function verify(runtime, view, bundle, testsPath) {
1893
2121
  const result = await runtime.examplarRun(
1894
2122
  testSource,
1895
2123
  JSON.stringify(bundle),
1896
- path4.basename(testsPath)
2124
+ path5.basename(testsPath)
1897
2125
  );
1898
2126
  if (!result.ok) {
1899
2127
  view.problem(`pll: ${result.error ?? "the bundle could not be run"}`);
1900
2128
  return EXIT.usage;
1901
2129
  }
1902
2130
  let bad = 0;
1903
- view.problem(`verifying with ${path4.basename(testsPath)}:`);
2131
+ view.problem(`verifying with ${path5.basename(testsPath)}:`);
1904
2132
  if (result.timedOut) {
1905
2133
  const { kind, id, targets, test, seconds } = result.timedOut;
1906
2134
  const where = kind === "wheat" ? `wheat ${id}` : `chaff ${targets}/${id}`;
@@ -1960,12 +2188,12 @@ async function verify(runtime, view, bundle, testsPath) {
1960
2188
  }
1961
2189
 
1962
2190
  // src/cli/runtime.ts
1963
- var fs6 = __toESM(require("node:fs"));
1964
- var path6 = __toESM(require("node:path"));
2191
+ var fs7 = __toESM(require("node:fs"));
2192
+ var path7 = __toESM(require("node:path"));
1965
2193
 
1966
2194
  // src/desktop/pyodideRuntime.ts
1967
- var fs5 = __toESM(require("node:fs"));
1968
- var path5 = __toESM(require("node:path"));
2195
+ var fs6 = __toESM(require("node:fs"));
2196
+ var path6 = __toESM(require("node:path"));
1969
2197
  var import_node_worker_threads = require("node:worker_threads");
1970
2198
 
1971
2199
  // src/run/python.ts
@@ -2046,10 +2274,27 @@ var Python = class extends WorkerConnection {
2046
2274
  /**
2047
2275
  * Run a student's tests against every implementation in a bundle.
2048
2276
  * `fileName` is the student's file, whose own errors are told from the
2049
- * implementation's.
2277
+ * implementation's; its definitions are given the modules its `#bundle`
2278
+ * lines name, loaded for `sessionKey`'s run (`bundleLoad`).
2279
+ */
2280
+ examplarRun(testSource, bundle, fileName, sessionKey = "") {
2281
+ return this.call("_pll_examplar_run", [testSource, bundle, fileName, sessionKey]);
2282
+ }
2283
+ /**
2284
+ * Compile a module into a bundle, for `pll bundle build`. Authoring only;
2285
+ * the bytecode is this interpreter's, as `examplarBuild`'s is.
2286
+ */
2287
+ bundleBuild(source, fileName) {
2288
+ return this.call("_pll_bundle_build", [source, fileName]);
2289
+ }
2290
+ /**
2291
+ * Make the modules a file's `#bundle` lines name, which `sessionKey`'s
2292
+ * next file run gives its program. `entries` is the JSON of
2293
+ * `[{name, json}]`, each `json` a fetched bundle. Their top levels run
2294
+ * here.
2050
2295
  */
2051
- examplarRun(testSource, bundle, fileName) {
2052
- return this.call("_pll_examplar_run", [testSource, bundle, fileName]);
2296
+ bundleLoad(sessionKey, entries) {
2297
+ return this.call("_pll_bundle_load", [sessionKey, entries]);
2053
2298
  }
2054
2299
  /**
2055
2300
  * Apply one event to a running reactor and get the frame it produced.
@@ -2097,7 +2342,7 @@ function deliverRunResult(result, onEvent, fileName) {
2097
2342
  function desktopPython(paths) {
2098
2343
  return new Python({
2099
2344
  indexUrl() {
2100
- const found = paths.indexUrlCandidates.find((dir) => fs5.existsSync(path5.join(dir, "pyodide.asm.wasm")));
2345
+ const found = paths.indexUrlCandidates.find((dir) => fs6.existsSync(path6.join(dir, "pyodide.asm.wasm")));
2101
2346
  if (!found) {
2102
2347
  throw new Error(paths.missingAssetsHint);
2103
2348
  }
@@ -2122,7 +2367,7 @@ function desktopPython(paths) {
2122
2367
  function createCliRuntime() {
2123
2368
  return desktopPython({
2124
2369
  indexUrlCandidates: pyodideDirectory(),
2125
- workerPath: path6.join(__dirname, "worker.cjs"),
2370
+ workerPath: path7.join(__dirname, "worker.cjs"),
2126
2371
  missingAssetsHint: "Could not find Pyodide's assets. Reinstall pll-python so its `pyodide` dependency is present.",
2127
2372
  // Ample for a first start on a slow machine; an autograder waits no
2128
2373
  // longer than that for a Pyodide that will never start.
@@ -2132,7 +2377,7 @@ function createCliRuntime() {
2132
2377
  }
2133
2378
  function writable(dir) {
2134
2379
  try {
2135
- fs6.accessSync(dir, fs6.constants.W_OK);
2380
+ fs7.accessSync(dir, fs7.constants.W_OK);
2136
2381
  return true;
2137
2382
  } catch {
2138
2383
  return false;
@@ -2141,15 +2386,15 @@ function writable(dir) {
2141
2386
  function userPackageCache(indexUrl) {
2142
2387
  let version = "unknown";
2143
2388
  try {
2144
- const manifest = fs6.readFileSync(path6.join(indexUrl, "package.json"), "utf8");
2389
+ const manifest = fs7.readFileSync(path7.join(indexUrl, "package.json"), "utf8");
2145
2390
  version = JSON.parse(manifest).version;
2146
2391
  } catch {
2147
2392
  }
2148
- return path6.join(cacheRoot(), `pyodide-${version}`);
2393
+ return path7.join(cacheRoot(), `pyodide-${version}`);
2149
2394
  }
2150
2395
  function pyodideDirectory() {
2151
2396
  try {
2152
- return [path6.dirname(require.resolve("pyodide"))];
2397
+ return [path7.dirname(require.resolve("pyodide"))];
2153
2398
  } catch {
2154
2399
  return [];
2155
2400
  }
@@ -2187,7 +2432,7 @@ function createStdinReader() {
2187
2432
  return Promise.resolve(null);
2188
2433
  }
2189
2434
  start();
2190
- return new Promise((resolve4) => waiting.push(resolve4));
2435
+ return new Promise((resolve5) => waiting.push(resolve5));
2191
2436
  },
2192
2437
  close() {
2193
2438
  ended = true;
@@ -2202,8 +2447,8 @@ function createStdinReader() {
2202
2447
  }
2203
2448
 
2204
2449
  // src/cli/view.ts
2205
- var fs7 = __toESM(require("node:fs"));
2206
- var path7 = __toESM(require("node:path"));
2450
+ var fs8 = __toESM(require("node:fs"));
2451
+ var path8 = __toESM(require("node:path"));
2207
2452
  var ESC = String.fromCharCode(27);
2208
2453
  var CliView = class {
2209
2454
  constructor(opts) {
@@ -2341,10 +2586,10 @@ var CliView = class {
2341
2586
  );
2342
2587
  return;
2343
2588
  }
2344
- const target = path7.join(dir, `image-${this.imageCount}.svg`);
2589
+ const target = path8.join(dir, `image-${this.imageCount}.svg`);
2345
2590
  try {
2346
- fs7.mkdirSync(dir, { recursive: true });
2347
- fs7.writeFileSync(target, svg, "utf8");
2591
+ fs8.mkdirSync(dir, { recursive: true });
2592
+ fs8.writeFileSync(target, svg, "utf8");
2348
2593
  this.note(this.dim(`[image ${width}x${height} -> ${target}]`));
2349
2594
  } catch (err) {
2350
2595
  this.problem(`Could not write ${target}: ${errorText(err)}`);
@@ -2462,6 +2707,8 @@ same everywhere.
2462
2707
 
2463
2708
  Subcommands
2464
2709
  examplar build author an Examplar bundle; see \`pll examplar --help\`
2710
+ bundle build compile a module a \`#bundle\` line names; see
2711
+ \`pll bundle --help\`
2465
2712
 
2466
2713
  Options
2467
2714
  --no-tests do not run the file's \`test_*\` functions, which
@@ -2480,7 +2727,8 @@ everything PLL says about the run goes to stderr.
2480
2727
  Exit codes
2481
2728
  0 ran, and any tests passed
2482
2729
  1 the program raised, or was stopped
2483
- 2 level checks found problems, so it was not run
2730
+ 2 level checks found problems, or a bundle could not be loaded,
2731
+ so it was not run
2484
2732
  3 a test failed
2485
2733
  64 bad usage, or Python could not start
2486
2734
  130 a second Ctrl+C gave up waiting for it to stop
@@ -2553,17 +2801,24 @@ function parseArgs2(argv) {
2553
2801
  }
2554
2802
  function isFile(at) {
2555
2803
  try {
2556
- return !fs8.statSync(at).isDirectory();
2804
+ return !fs9.statSync(at).isDirectory();
2557
2805
  } catch {
2558
2806
  return false;
2559
2807
  }
2560
2808
  }
2809
+ var SUBCOMMANDS = {
2810
+ examplar: runExamplar,
2811
+ bundle: runBundle
2812
+ };
2561
2813
  async function main(argv) {
2562
- const at = subcommandIndex(argv, "examplar", OPTIONS);
2563
- if (at !== -1) {
2814
+ for (const [name, run] of Object.entries(SUBCOMMANDS)) {
2815
+ const at = subcommandIndex(argv, name, OPTIONS);
2816
+ if (at === -1) {
2817
+ continue;
2818
+ }
2564
2819
  const leading = parseCommandLine(argv.slice(0, at), LEADING_OPTIONS);
2565
2820
  if ("error" in leading) {
2566
- process.stderr.write(`pll: ${leading.error} before examplar
2821
+ process.stderr.write(`pll: ${leading.error} before ${name}
2567
2822
  ${USAGE_HINT}
2568
2823
  `);
2569
2824
  return EXIT.usage;
@@ -2575,7 +2830,7 @@ ${USAGE_HINT}
2575
2830
  const runtime2 = createCliRuntime();
2576
2831
  sayPackageNotes(runtime2, view2);
2577
2832
  try {
2578
- return await runExamplar(runtime2, view2, argv.slice(at + 1));
2833
+ return await run(runtime2, view2, argv.slice(at + 1));
2579
2834
  } catch (err) {
2580
2835
  view2.problem(`pll: ${errorText(err)}`);
2581
2836
  return EXIT.usage;
@@ -2589,7 +2844,7 @@ ${USAGE_HINT}
2589
2844
  return EXIT.ok;
2590
2845
  }
2591
2846
  if (args.version) {
2592
- process.stdout.write("0.9.0\n");
2847
+ process.stdout.write("0.9.2\n");
2593
2848
  return EXIT.ok;
2594
2849
  }
2595
2850
  if (args.error !== void 0) {
@@ -2629,7 +2884,7 @@ ${USAGE_HINT}
2629
2884
  process.stderr.on("error", onBrokenPipe);
2630
2885
  try {
2631
2886
  return await runFile(runtime, view, {
2632
- file: path8.resolve(args.file),
2887
+ file: path9.resolve(args.file),
2633
2888
  runTests: args.runTests,
2634
2889
  stopRequested: () => interrupted
2635
2890
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pll-python",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Run Python with PLL's language levels from the command line: level checks, in-file tests, friendly errors and the built-in image and table libraries, all on Pyodide. No Python install needed.",
5
5
  "license": "MIT",
6
6
  "repository": {