• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

source-academy / py-slang / 30788832906

03 Aug 2026 06:01AM UTC coverage: 86.029% (-0.1%) from 86.155%
30788832906

push

github

web-flow
Stepper: support a subset of module imports (#386)

* Make the Python stepper handle a subset of module imports (#385)

`from X import Y` used to make the stepper report "Evaluation stuck"
the instant it saw the import line, regardless of whether the
imported name was ever used. This wires up real module resolution
for the substitution stepper, mirroring the CSE machine's own
two-phase (load-then-run) model:

- FromImport now translates to a no-op statement instead of an inert
  placeholder identifier, rendering the student's actual import text.
- New OpaqueValue/ModuleFunction StepNode kinds represent opaque
  module handles and imported callables as legitimate, substitutable
  values.
- ModuleLoaderRunnerPlugin is registered on PyStepperEvaluatorBase,
  exactly as the CSE/py2js/Pvml evaluators already do.
- moduleInterop.ts resolves and substitutes a program's imports
  before stepping begins, and calls imported functions (trying the
  synchronous closure_call_sync fast path first, falling back to
  draining the async closure_call_unchecked generator). A Python
  closure passed as an argument into a module call is declined —
  that stays an honest "Evaluation stuck", the same degrade input()
  already gets — since supporting it would require re-entering the
  reducer's own step machinery mid-call.
- reduce.ts/getSteps.ts are now async end-to-end to support this.

Verified with integration tests against a fake module (the same
GenericDataHandler + ModuleLoaderRunnerPlugin harness
py2js-from-import.test.ts uses), not just the no-evaluator-wired
degrade path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w4FqCep4j8XyFpF4kpVue

* Address CodeRabbit review on #386

- moduleInterop.ts: use Promise.allSettled (not Promise.all) when
  requesting a program's modules, so a second rejection is observed
  instead of becoming an unhandled rejection racing the first one's
  throw. Preserve the loader's ... (continued)

4657 of 5836 branches covered (79.8%)

Branch coverage included in aggregate %.

129 of 158 new or added lines in 8 files covered. (81.65%)

2 existing lines in 2 files now uncovered.

10386 of 11650 relevant lines covered (89.15%)

166941.65 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

68.15
/src/conductor/stepper/moduleInterop.ts
1
/**
2
 * Module interop for the Python substitution stepper (`from X import Y`, py-slang#385).
3
 *
4
 * The stepper's own `StepNode`-flavoured sibling of `src/engines/cse/modules.ts`'s
5
 * `moduleToPython`/`pythonToModule` — and, like `src/engines/py2js/moduleInterop.ts`, a second proof
6
 * that conductor's module protocol doesn't require the CSE machine's own control/stash re-entrant
7
 * instruction loop to consume: a plain recursive `async`/`await` conversion layer is enough, as long
8
 * as the engine calling it has an `async` path of its own to await from (see `reduce.ts`'s
9
 * `contractCall`, which is what this module is called from).
10
 *
11
 * Scope, deliberately narrower than the other two engines: a Python-authored callable (a `lambda`/
12
 * `def`, or a bare reference to a static built-in) is never converted into a module argument — see
13
 * `isPythonCallable`. Forwarding one would require the module to call back into Python mid-call,
14
 * which (unlike CSE's `modules.ts`, whose `"closure"` case re-enters its own control/stash loop, or
15
 * py2js's, which recurses into its own async interpreter entry point) the substitution reducer has no
16
 * way to do without re-entering its own step machinery — out of scope for this pass. A call shaped
17
 * that way simply doesn't reduce (irreducible → "Evaluation stuck"), the same honest degrade
18
 * `builtins.ts` already documents for `input()`/`time_time()`. A `ModuleFunction` value (an *already*
19
 * module-owned closure — see `ast.ts`) is fine to forward: passing it back never re-enters Python,
20
 * only the module's own native call machinery.
21
 *
22
 * `callModuleFunction` tries `IDataHandler.closure_call_sync` first (a genuinely synchronous escape
23
 * hatch a module's exported closure may opt into — see `GenericDataHandler.closure_call_sync`'s doc
24
 * comment) before falling back to draining the mandatory `closure_call_unchecked` async generator.
25
 * Per that same doc comment essentially no module (including presumably `rune`) opts in today, so
26
 * this doesn't remove the need for the `async` path, but it costs nothing to prefer it when available.
27
 */
28

29
import { DataType, type IDataHandler, type TypedValue } from "@sourceacademy/conductor/types";
3✔
30
import { ModuleLoaderRunnerPlugin } from "@sourceacademy/runner-module-loader";
3✔
31

32
import type { StmtNS } from "../../ast-types";
33
import { RELATIVE_IMPORT_NOT_SUPPORTED_MESSAGE } from "../../errors";
3✔
34
import {
3✔
35
  literal,
36
  moduleFunction,
37
  numberLiteral,
38
  opaqueValue,
39
  type StepNode,
40
  stringLiteral,
41
  substitute,
42
} from "./ast";
43
import { isBuiltinFunctionName } from "./builtins";
3✔
44

45
/** Thrown for a student-actionable import problem (module not found, name not exported, a relative
46
 * import) — surfaced by `getSteps.ts`'s callers the same way a preprocessing error is, rather than
47
 * left to manifest later as a confusing "Evaluation stuck" deep into a run. `cause` (when the loader
48
 * itself rejected, e.g. a module-not-found case) is set as a plain field rather than via `Error`'s
49
 * two-argument constructor: this project's `lib` target predates `ErrorOptions`/`cause`. */
50
export class ModuleImportError extends Error {
3✔
51
  constructor(message: string, options?: { cause?: unknown }) {
52
    super(message);
4✔
53
    this.cause = options?.cause;
4✔
54
  }
55
  readonly cause?: unknown;
56
}
57

58
/** Thrown internally by `stepNodeToModule` for a value shape this interop layer cannot cross the
59
 * module boundary with. Turned into a graceful "Evaluation stuck" by `contractCall`, exactly like any
60
 * other runtime fault the reducer raises — never allowed to escape as an unhandled rejection. */
61
class ModuleInteropUnsupportedError extends Error {}
62

63
/** A genuine Python-authored callable — the one value shape `stepNodeToModule` declines to forward
64
 * into a module call. See the module doc comment. */
65
function isPythonCallable(node: StepNode): boolean {
66
  return (
3✔
67
    node.type === "ArrowFunctionExpression" ||
7!
68
    node.type === "FunctionDeclaration" ||
69
    (node.type === "Identifier" && isBuiltinFunctionName(String(node.name)))
70
  );
71
}
72

73
/** Converts a stepper value into a conductor `TypedValue`, for passing into a module call as an
74
 * argument. Mirrors `pythonToModule` in `src/engines/cse/modules.ts`, restricted per the module doc
75
 * comment above. Throws `ModuleInteropUnsupportedError` for anything it cannot convert. */
76
export async function stepNodeToModule(
3✔
77
  evaluator: IDataHandler,
78
  node: StepNode,
79
): Promise<TypedValue<DataType>> {
80
  if (isPythonCallable(node)) {
3✔
81
    throw new ModuleInteropUnsupportedError(
1✔
82
      "a Python function value cannot be passed to an imported module function here",
83
    );
84
  }
85
  switch (node.type) {
2!
86
    case "Literal": {
87
      const v = node.value;
1✔
88
      if (v === null) return { type: DataType.EMPTY_LIST, value: null };
1!
89
      if (typeof v === "boolean") return { type: DataType.BOOLEAN, value: v };
1!
90
      if (typeof v === "bigint") return { type: DataType.NUMBER, value: Number(v) };
1✔
NEW
91
      if (typeof v === "number") return { type: DataType.NUMBER, value: v };
×
NEW
92
      if (typeof v === "string") return { type: DataType.CONST_STRING, value: v };
×
93
      // A complex number: not supported crossing the module boundary, mirroring CSE's/py2js's
94
      // identical restriction.
NEW
95
      throw new ModuleInteropUnsupportedError("complex values are not supported in module interop");
×
96
    }
97
    case "ArrayExpression": {
NEW
98
      const elements = await Promise.all(
×
NEW
99
        (node.elements as StepNode[]).map(el => stepNodeToModule(evaluator, el)),
×
100
      );
NEW
101
      const array = await evaluator.array_make(DataType.ANY, elements.length, {
×
102
        type: DataType.VOID,
103
        value: undefined,
104
      });
NEW
105
      for (let i = 0; i < elements.length; i++) {
×
NEW
106
        await evaluator.array_set(
×
107
          array as unknown as TypedValue<DataType.ARRAY, DataType.VOID>,
108
          i,
109
          elements[i],
110
        );
111
      }
NEW
112
      return array;
×
113
    }
114
    case "Opaque":
115
      return node.handle as TypedValue<DataType.OPAQUE>;
1✔
116
    case "ModuleFunction":
NEW
117
      return node.closure as TypedValue<DataType.CLOSURE>;
×
118
    default:
NEW
119
      throw new ModuleInteropUnsupportedError(
×
120
        `a ${node.type} value cannot be passed to an imported module function here`,
121
      );
122
  }
123
}
124

125
/** Reads a PAIR or ARRAY's elements uniformly — see the identical helper's doc comment in
126
 * `src/engines/cse/modules.ts`. */
127
async function readCompoundElements(
128
  evaluator: IDataHandler,
129
  value: TypedValue<DataType.ARRAY> | TypedValue<DataType.PAIR>,
130
): Promise<TypedValue<DataType>[]> {
NEW
131
  if (value.type === DataType.PAIR) {
×
NEW
132
    return [await evaluator.pair_head(value), await evaluator.pair_tail(value)];
×
133
  }
NEW
134
  const length = await evaluator.array_length(value);
×
NEW
135
  return Promise.all(Array.from({ length }, (_, i) => evaluator.array_get(value, i)));
×
136
}
137

138
/** Converts a conductor `TypedValue` into a stepper value — a module export flowing into a Python
139
 * program, or a module function's return value. Mirrors `moduleToPython` in
140
 * `src/engines/cse/modules.ts`. `name` labels a `DataType.CLOSURE` result (the display name calls to
141
 * it render as); defaults to a generic label for one reached indirectly (e.g. a module function
142
 * returning another as its result). */
143
export async function moduleToStepNode(
3✔
144
  evaluator: IDataHandler,
145
  value: TypedValue<DataType>,
146
  name = "<module function>",
×
147
): Promise<StepNode> {
148
  switch (value.type) {
14!
149
    case DataType.NUMBER:
150
      // A module number is always a float — mirrors CSE's/py2js's identical stance (integers stay
151
      // out of the module interface entirely).
152
      return numberLiteral(value.value, true);
2✔
153
    case DataType.INTEGER:
154
      // py-slang never produces DataType.INTEGER itself; only here for switch exhaustiveness over
155
      // conductor's DataType enum, mirroring the other two engines' identical case.
NEW
156
      return numberLiteral(Number(value.value), true);
×
157
    case DataType.BOOLEAN:
158
      return literal(value.value, value.value ? "True" : "False");
1!
159
    case DataType.CONST_STRING:
NEW
160
      return stringLiteral(value.value);
×
161
    case DataType.VOID:
162
    case DataType.EMPTY_LIST:
NEW
163
      return literal(null, "None");
×
164
    case DataType.OPAQUE: {
165
      const payload = await evaluator.opaque_get(value);
3✔
166
      const ctorName =
167
        payload !== null && typeof payload === "object"
3!
168
          ? (payload as { constructor?: { name?: string } }).constructor?.name
169
          : undefined;
170
      return opaqueValue(ctorName ?? "opaque", value);
3!
171
    }
172
    case DataType.CLOSURE: {
173
      const [minArgs, isVararg] = await Promise.all([
8✔
174
        evaluator.closure_arity(value),
175
        evaluator.closure_is_vararg(value),
176
      ]);
177
      return moduleFunction(name, value, minArgs, isVararg);
8✔
178
    }
179
    case DataType.PAIR:
180
    case DataType.ARRAY: {
181
      // Untyped and recursive, uniformly for both — mirrors CSE's/py2js's identical non-distinction
182
      // between a PAIR and an ARRAY (e.g. sound's Sound is a (wave, duration) dotted pair).
NEW
183
      const elements = await readCompoundElements(evaluator, value);
×
NEW
184
      const converted = await Promise.all(
×
NEW
185
        elements.map(el => moduleToStepNode(evaluator, el, name)),
×
186
      );
NEW
187
      return { type: "ArrayExpression", elements: converted };
×
188
    }
189
  }
190
}
191

192
/**
193
 * Calls an imported module function. Tries the synchronous fast path first (see the module doc
194
 * comment), falling back to draining the mandatory async-generator call. Throws
195
 * `ModuleInteropUnsupportedError` (caught by `contractCall`, same as any other runtime fault) if an
196
 * argument can't cross the module boundary — see `stepNodeToModule`.
197
 */
198
export async function callModuleFunction(
3✔
199
  evaluator: IDataHandler,
200
  closure: TypedValue<DataType.CLOSURE>,
201
  name: string,
202
  args: StepNode[],
203
): Promise<StepNode> {
204
  const moduleArgs = await Promise.all(args.map(a => stepNodeToModule(evaluator, a)));
6✔
205
  const syncCall = (
206
    evaluator as IDataHandler & {
5✔
207
      closure_call_sync?: (
208
        c: TypedValue<DataType.CLOSURE>,
209
        callArgs: TypedValue<DataType>[],
210
      ) => TypedValue<DataType> | undefined;
211
    }
212
  ).closure_call_sync?.bind(evaluator);
213
  const syncResult = syncCall?.(closure, moduleArgs);
5✔
214
  if (syncResult !== undefined) {
5!
NEW
215
    return moduleToStepNode(evaluator, syncResult, name);
×
216
  }
217
  const gen = evaluator.closure_call_unchecked(closure, moduleArgs);
5✔
218
  let step = await gen.next();
5✔
219
  while (!step.done) step = await gen.next();
5✔
220
  return moduleToStepNode(evaluator, step.value, name);
5✔
221
}
222

223
/**
224
 * Resolves and binds every `FromImport` a program uses, before stepping begins — mirroring
225
 * `src/engines/cse/modules.ts`'s `loadModules`/`evaluateImports` (module loading happens once, ahead
226
 * of running/stepping the program itself, matching every other py-slang evaluator's two-phase model).
227
 * Each imported name is substituted directly into `program` (exactly like `substituteBuiltinConstants`
228
 * substitutes a built-in constant) rather than looked up by name at call time, so nothing needs
229
 * threading through the reducer beyond the one `evaluator` parameter `contractCall` needs to actually
230
 * place a call.
231
 *
232
 * `evaluator` is `undefined` when no module loader is wired up (e.g. a bare `getPythonSteps()` call in
233
 * a test, or `PyStepperEvaluatorBase` in a host that hasn't registered `ModuleLoaderRunnerPlugin`) —
234
 * every imported name is then simply left unbound, exactly matching this file's absence: a program
235
 * that never uses the name still reaches "Evaluation complete" (`translate.ts`'s `FromImport` → a
236
 * no-op statement), one that does gets stuck at the point of use, not here. A relative import, a
237
 * missing module, or a name a module doesn't export are all *student-actionable* mistakes, though, so
238
 * once an evaluator genuinely is available those throw `ModuleImportError` rather than degrading —
239
 * mirrors how CSE (`RelativeImportNotSupportedError`/`ModuleNotFoundError`) and py2js
240
 * (`loadChunkImports`) both treat the identical cases as hard errors, not silent no-ops.
241
 */
242
export async function resolveImports(
3✔
243
  fileInput: StmtNS.FileInput,
244
  evaluator: IDataHandler | undefined,
245
  program: StepNode,
246
): Promise<StepNode> {
247
  const imports = fileInput.statements.filter(
2,527✔
248
    (s): s is StmtNS.FromImport => s.kind === "FromImport",
2,626✔
249
  );
250
  if (imports.length === 0) return program;
2,527✔
251

252
  const offending = imports.find(s => s.level > 0);
17✔
253
  if (offending !== undefined) {
17✔
254
    throw new ModuleImportError(RELATIVE_IMPORT_NOT_SUPPORTED_MESSAGE);
1✔
255
  }
256

257
  if (evaluator === undefined || ModuleLoaderRunnerPlugin.instance === null) {
16✔
258
    return program;
5✔
259
  }
260
  const loader = ModuleLoaderRunnerPlugin.instance;
11✔
261

262
  const moduleNames = [...new Set(imports.map(s => s.module.lexeme))];
11✔
263
  // `allSettled`, not `all`: every module is requested regardless of whether an earlier one
264
  // rejects, so a second rejection is observed (and its promise handled) rather than becoming an
265
  // unhandled rejection racing the first one's `throw` below. `cause` preserves the loader's own
266
  // rejection reason (a genuine load failure, not just "not found") for diagnosis.
267
  const settled = await Promise.allSettled(moduleNames.map(name => loader.requestModule(name)));
11✔
268
  const plugins = new Map<string, Awaited<ReturnType<typeof loader.requestModule>>>();
11✔
269
  for (let i = 0; i < settled.length; i++) {
11✔
270
    const outcome = settled[i];
11✔
271
    const moduleName = moduleNames[i];
11✔
272
    if (outcome.status === "rejected") {
11✔
273
      throw new ModuleImportError(`Module "${moduleName}" not found.`, { cause: outcome.reason });
2✔
274
    }
275
    plugins.set(moduleName, outcome.value);
9✔
276
  }
277

278
  // Binding runs sequentially in source order (not concurrently) so two imports binding the same
279
  // name resolve deterministically — last one in source order wins, matching plain reassignment —
280
  // rather than racing on whichever conversion happens to finish last; mirrors py2js's
281
  // `loadChunkImports`' identical ordering rationale.
282
  let result = program;
9✔
283
  for (const stmt of imports) {
9✔
284
    const moduleName = stmt.module.lexeme;
9✔
285
    const exportsByName = new Map(plugins.get(moduleName)!.exports.map(e => [e.symbol, e.value]));
10✔
286
    for (const spec of stmt.names) {
9✔
287
      const exportValue = exportsByName.get(spec.name.lexeme);
10✔
288
      if (exportValue === undefined) {
10✔
289
        throw new ModuleImportError(
1✔
290
          `cannot import name '${spec.name.lexeme}' from '${moduleName}'`,
291
        );
292
      }
293
      const bound = (spec.alias ?? spec.name).lexeme;
9✔
294
      const value = await moduleToStepNode(evaluator, exportValue, spec.name.lexeme);
9✔
295
      result = substitute(result, bound, value);
9✔
296
    }
297
  }
298
  return result;
8✔
299
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc