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

source-academy / py-slang / 29985107634

23 Jul 2026 06:27AM UTC coverage: 86.13%. Remained the same
29985107634

push

github

web-flow
Add set_timeout/clear_all_timeout to MISC (py2js) (#322)

* Add set_timeout/clear_all_timeout to MISC, implemented for py2js

Moves sound_matrix's own set_timeout(f, t)/clear_all_timeout() into the
language itself (misc.ts's MISC group) rather than keeping it module-gated
— same signature and units, needed for the tone matrix quest (Q5B).

py2js needs no cold-callback re-entry machinery to support this: a
compiled Python function already is a plain JS closure, so a real
setTimeout callback can call it directly on the event loop. Scheduled via
rt.acall (the async trampoline, not the sync call path) so a callback that
itself needs a frontend round-trip — e.g. calling an imported module
function — still works. An error raised inside a fired callback can no
longer reach the chunk that scheduled it (already resolved by then), so
it's reported through onOutput instead of silently lost. Pending timeouts
are tracked per Py2JsRuntime so clear_all_timeout only cancels the current
run's own callbacks.

CSE, PVML, and WASM aren't implemented yet — CSE has an explicit stub
("not yet supported by the CSE machine"), and both PVML and WASM fail
their own way (a PVML "primitive not implemented" RunError and a WASM
runtime NameError respectively, since each has its own separate primitive/
codegen dispatch that set_timeout was never registered in). All three are
tracked as their own follow-ups: #319 (CSE), #320 (PVML), #321 (WASM).

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

* Fix prettier formatting in py2js-set-timeout.test.ts

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

* Route set_timeout/clear_all_timeout stubs through handleRuntimeError

Addresses CodeRabbit review on #322. Both stubs threw a raw JS Error
instead of the standard CSE runtime-error path (handleRuntimeError + a
RuntimeSourceError su... (continued)

4394 of 5491 branches covered (80.02%)

Branch coverage included in aggregate %.

19 of 21 new or added lines in 2 files covered. (90.48%)

9510 of 10652 relevant lines covered (89.28%)

178721.58 hits per line

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

88.38
/src/stdlib/misc.ts
1
import { ExprNS } from "../ast-types";
2
import { Context } from "../engines/cse/context";
3
import { handleRuntimeError } from "../engines/cse/error";
53✔
4
import {
5
  BigIntValue,
6
  BoolValue,
7
  BuiltinValue,
8
  ComplexValue,
9
  NoneValue,
10
  NumberValue,
11
  StringValue,
12
  Value,
13
} from "../engines/cse/stash";
14
import { displayOutput, receiveInput } from "../engines/cse/streams";
53✔
15
import { isNumeric } from "../engines/cse/utils";
53✔
16
import { TypeError, UserError } from "../errors";
53✔
17
import { PyComplexNumber } from "../types";
53✔
18
import { GroupName, minArgMap, toPythonString, Validate } from "./utils";
53✔
19

20
const miscBuiltins = new Map<string, BuiltinValue>();
53✔
21

22
export class MiscBuiltins {
53✔
23
  @Validate(1, 1, "arity", true)
24
  static arity(args: Value[], source: string, command: ExprNS.Call, context: Context): BigIntValue {
53✔
25
    const func = args[0];
115✔
26
    if (func.type !== "builtin" && func.type !== "closure") {
115✔
27
      handleRuntimeError(context, new TypeError(source, command, context, func.type));
20✔
28
    }
29
    if (func.type === "closure") {
95✔
30
      const variadicInstance = func.closure.node.parameters.findIndex(param => param.isStarred);
16✔
31
      if (variadicInstance !== -1) {
15✔
32
        return { type: "bigint", value: BigInt(variadicInstance) };
4✔
33
      }
34
      return { type: "bigint", value: BigInt(func.closure.node.parameters.length) };
11✔
35
    }
36
    return { type: "bigint", value: BigInt(func.minArgs) };
80✔
37
  }
38

39
  @Validate(null, 2, "complex", true)
40
  static complex(
53✔
41
    args: Value[],
42
    source: string,
43
    command: ExprNS.Call,
44
    context: Context,
45
  ): ComplexValue {
46
    if (args.length === 0) {
106✔
47
      return { type: "complex", value: new PyComplexNumber(0, 0) };
5✔
48
    }
49
    if (args.length == 1) {
101✔
50
      const val = args[0];
57✔
51
      if (
57✔
52
        val.type !== "bigint" &&
213✔
53
        val.type !== "number" &&
54
        val.type !== "bool" &&
55
        val.type !== "string" &&
56
        val.type !== "complex"
57
      ) {
58
        handleRuntimeError(context, new TypeError(source, command, context, val.type));
13✔
59
      }
60
      return {
44✔
61
        type: "complex",
62
        value: PyComplexNumber.fromValue(context, source, command, val.value),
63
      };
64
    }
65
    const invalidType = args.filter(
44✔
66
      val =>
67
        val.type !== "bigint" &&
88✔
68
        val.type !== "number" &&
69
        val.type !== "bool" &&
70
        val.type !== "complex",
71
    );
72
    if (invalidType.length > 0) {
44✔
73
      handleRuntimeError(context, new TypeError(source, command, context, invalidType[0].type));
3✔
74
    }
75
    const [real, imag] = args as (BigIntValue | NumberValue | BoolValue | ComplexValue)[];
41✔
76
    const realPart = PyComplexNumber.fromValue(context, source, command, real.value);
41✔
77
    const imagPart = PyComplexNumber.fromValue(context, source, command, imag.value);
41✔
78
    return { type: "complex", value: realPart.add(imagPart.mul(new PyComplexNumber(0, 1))) };
41✔
79
  }
80

81
  @Validate(1, 1, "real", true)
82
  static real(args: Value[], source: string, command: ExprNS.Call, context: Context): NumberValue {
53✔
83
    const val = args[0];
32✔
84
    if (val.type !== "complex") {
32✔
85
      handleRuntimeError(context, new TypeError(source, command, context, val.type));
26✔
86
    }
87
    return { type: "number", value: val.value.real };
6✔
88
  }
89

90
  @Validate(1, 1, "imag", true)
91
  static imag(args: Value[], source: string, command: ExprNS.Call, context: Context): NumberValue {
53✔
92
    const val = args[0];
32✔
93
    if (val.type !== "complex") {
32✔
94
      handleRuntimeError(context, new TypeError(source, command, context, val.type));
26✔
95
    }
96
    return { type: "number", value: val.value.imag };
6✔
97
  }
98

99
  @Validate(1, 1, "abs", false)
100
  static abs(
53✔
101
    args: Value[],
102
    source: string,
103
    command: ExprNS.Call,
104
    context: Context,
105
  ): BigIntValue | NumberValue {
106
    const x = args[0];
54✔
107
    switch (x.type) {
54✔
108
      case "bigint": {
109
        const intVal = x.value;
20✔
110
        const result: bigint = intVal < 0 ? -intVal : intVal;
20✔
111
        return { type: "bigint", value: result };
20✔
112
      }
113
      case "number": {
114
        return { type: "number", value: Math.abs(x.value) };
6✔
115
      }
116
      case "complex": {
117
        // Calculate the modulus (absolute value) of a complex number.
118
        const real = x.value.real;
8✔
119
        const imag = x.value.imag;
8✔
120
        const modulus = Math.hypot(real, imag);
8✔
121
        return { type: "number", value: modulus };
8✔
122
      }
123
      default:
124
        handleRuntimeError(context, new TypeError(source, command, context, args[0].type));
20✔
125
    }
126
  }
127

128
  @Validate(1, 1, "len", true)
129
  static len(args: Value[], source: string, command: ExprNS.Call, context: Context): BigIntValue {
53✔
130
    const val = args[0];
48✔
131
    if (val.type === "string" || val.type === "list") {
48✔
132
      // The spread operator is used to count the number of Unicode code points
133
      // in the string
134
      return { type: "bigint", value: BigInt([...val.value].length) };
17✔
135
    }
136
    handleRuntimeError(context, new TypeError(source, command, context, val.type));
31✔
137
  }
138

139
  static error(args: Value[], _source: string, command: ExprNS.Call, context: Context): Value {
140
    const output = "Error: " + args.map(arg => toPythonString(arg)).join(" ") + "\n";
70✔
141
    handleRuntimeError(context, new UserError(output, command));
56✔
142
  }
143

144
  @Validate(2, null, "max", true)
145
  static max(args: Value[], source: string, command: ExprNS.Call, context: Context): Value {
53✔
146
    if (args.every(isNumeric) || args.every(arg => arg.type === "string")) {
25✔
147
      let maxIndex = 0;
25✔
148
      for (let i = 1; i < args.length; i++) {
25✔
149
        if (args[i].value > args[maxIndex].value) {
32✔
150
          maxIndex = i;
16✔
151
        }
152
      }
153
      return args[maxIndex];
25✔
154
    }
155
    if (isNumeric(args[0])) {
×
156
      const invalidType = args.find(arg => !isNumeric(arg))!;
×
157
      handleRuntimeError(context, new TypeError(source, command, context, invalidType.type));
×
158
    } else if (args[0].type === "string") {
×
159
      const invalidType = args.find(arg => arg.type !== "string")!;
×
160
      handleRuntimeError(context, new TypeError(source, command, context, invalidType.type));
×
161
    } else {
162
      handleRuntimeError(context, new TypeError(source, command, context, args[0].type));
×
163
    }
164
  }
165

166
  @Validate(2, null, "min", true)
167
  static min(args: Value[], source: string, command: ExprNS.Call, context: Context): Value {
53✔
168
    if (args.every(isNumeric) || args.every(arg => arg.type === "string")) {
18!
169
      let minIndex = 0;
18✔
170
      for (let i = 1; i < args.length; i++) {
18✔
171
        if (args[i].value < args[minIndex].value) {
20✔
172
          minIndex = i;
6✔
173
        }
174
      }
175
      return args[minIndex];
18✔
176
    }
177
    if (isNumeric(args[0])) {
×
178
      const invalidType = args.find(arg => !isNumeric(arg))!;
×
179
      handleRuntimeError(context, new TypeError(source, command, context, invalidType.type));
×
180
    } else if (args[0].type === "string") {
×
181
      const invalidType = args.find(arg => arg.type !== "string")!;
×
182
      handleRuntimeError(context, new TypeError(source, command, context, invalidType.type));
×
183
    } else {
184
      handleRuntimeError(context, new TypeError(source, command, context, args[0].type));
×
185
    }
186
  }
187

188
  @Validate(null, 0, "random_random", true)
189
  static random_random(
53✔
190
    _args: Value[],
191
    _source: string,
192
    _command: ExprNS.Call,
193
    _context: Context,
194
  ): NumberValue {
195
    const result = Math.random();
4✔
196
    return { type: "number", value: result };
4✔
197
  }
198

199
  @Validate(1, 2, "round", true)
200
  static round(
53✔
201
    args: Value[],
202
    source: string,
203
    command: ExprNS.Call,
204
    context: Context,
205
  ): NumberValue | BigIntValue {
206
    const numArg = args[0];
63✔
207
    if (!isNumeric(numArg)) {
63✔
208
      handleRuntimeError(context, new TypeError(source, command, context, numArg.type));
24✔
209
    }
210

211
    let ndigitsArg: BigIntValue = { type: "bigint", value: BigInt(0) };
39✔
212
    if (args.length === 2 && args[1].type !== "none") {
39✔
213
      if (args[1].type !== "bigint") {
25✔
214
        handleRuntimeError(context, new TypeError(source, command, context, args[1].type));
9✔
215
      }
216
      ndigitsArg = args[1];
16✔
217
    } else {
218
      const shifted = Intl.NumberFormat("en-US", {
14✔
219
        roundingMode: "halfEven",
220
        useGrouping: false,
221
        maximumFractionDigits: 0,
222
      } as Intl.NumberFormatOptions).format(numArg.value);
223
      return { type: "bigint", value: BigInt(shifted) };
14✔
224
    }
225

226
    if (numArg.type === "number") {
16✔
227
      const numberValue: number = numArg.value;
10✔
228
      if (ndigitsArg.value >= 0) {
10✔
229
        const shifted = Intl.NumberFormat("en-US", {
9✔
230
          roundingMode: "halfEven",
231
          useGrouping: false,
232
          maximumFractionDigits: Number(ndigitsArg.value),
233
        } as Intl.NumberFormatOptions).format(numberValue);
234
        return { type: "number", value: Number(shifted) };
9✔
235
      } else {
236
        const shifted = Intl.NumberFormat("en-US", {
1✔
237
          roundingMode: "halfEven",
238
          useGrouping: false,
239
          maximumFractionDigits: 0,
240
        } as Intl.NumberFormatOptions).format(numArg.value / 10 ** -Number(ndigitsArg.value));
241
        return { type: "number", value: Number(shifted) * 10 ** -Number(ndigitsArg.value) };
1✔
242
      }
243
    } else {
244
      if (ndigitsArg.value >= 0) {
6!
245
        return numArg;
6✔
246
      } else {
247
        const shifted = Intl.NumberFormat("en-US", {
×
248
          roundingMode: "halfEven",
249
          useGrouping: false,
250
          maximumFractionDigits: 0,
251
        } as Intl.NumberFormatOptions).format(
252
          Number(numArg.value) / 10 ** -Number(ndigitsArg.value),
253
        );
254
        return { type: "bigint", value: BigInt(shifted) * 10n ** -ndigitsArg.value };
×
255
      }
256
    }
257
  }
258

259
  @Validate(null, 0, "time_time", true)
260
  static time_time(
53✔
261
    _args: Value[],
262
    _source: string,
263
    _command: ExprNS.Call,
264
    _context: Context,
265
  ): NumberValue {
266
    // Python's time.time() is documented as seconds since the epoch, not milliseconds — divide
267
    // Date.now()'s milliseconds down to match.
268
    return { type: "number", value: Date.now() / 1000 };
4✔
269
  }
270

271
  @Validate(1, 1, "is_none", true)
272
  static is_none(
53✔
273
    args: Value[],
274
    _source: string,
275
    _command: ExprNS.Call,
276
    _context: Context,
277
  ): BoolValue {
278
    const obj = args[0];
660✔
279
    return { type: "bool", value: obj.type === "none" };
660✔
280
  }
281

282
  @Validate(1, 1, "is_float", true)
283
  static is_float(
53✔
284
    args: Value[],
285
    _source: string,
286
    _command: ExprNS.Call,
287
    _context: Context,
288
  ): BoolValue {
289
    const obj = args[0];
39✔
290
    return { type: "bool", value: obj.type === "number" };
39✔
291
  }
292

293
  @Validate(1, 1, "is_string", true)
294
  static is_string(
53✔
295
    args: Value[],
296
    _source: string,
297
    _command: ExprNS.Call,
298
    _context: Context,
299
  ): BoolValue {
300
    const obj = args[0];
48✔
301
    return { type: "bool", value: obj.type === "string" };
48✔
302
  }
303

304
  @Validate(1, 1, "is_boolean", true)
305
  static is_boolean(
53✔
306
    args: Value[],
307
    _source: string,
308
    _command: ExprNS.Call,
309
    _context: Context,
310
  ): BoolValue {
311
    const obj = args[0];
40✔
312
    return { type: "bool", value: obj.type === "bool" };
40✔
313
  }
314

315
  @Validate(1, 1, "is_complex", true)
316
  static is_complex(
53✔
317
    args: Value[],
318
    _source: string,
319
    _command: ExprNS.Call,
320
    _context: Context,
321
  ): BoolValue {
322
    const obj = args[0];
30✔
323
    return { type: "bool", value: obj.type === "complex" };
30✔
324
  }
325

326
  @Validate(1, 1, "is_number", true)
327
  static is_number(
53✔
328
    args: Value[],
329
    _source: string,
330
    _command: ExprNS.Call,
331
    _context: Context,
332
  ): BoolValue {
333
    // Mirrors Scheme's `number?`: true for any number in the numeric tower
334
    // (integer, float or complex), but not for booleans.
335
    const obj = args[0];
39✔
336
    return {
39✔
337
      type: "bool",
338
      value: obj.type === "bigint" || obj.type === "number" || obj.type === "complex",
101✔
339
    };
340
  }
341

342
  @Validate(1, 1, "is_integer", true)
343
  static is_integer(
53✔
344
    args: Value[],
345
    _source: string,
346
    _command: ExprNS.Call,
347
    _context: Context,
348
  ): BoolValue {
349
    const obj = args[0];
39✔
350
    return { type: "bool", value: obj.type === "bigint" };
39✔
351
  }
352

353
  @Validate(1, 1, "is_function", true)
354
  static is_function(
53✔
355
    args: Value[],
356
    _source: string,
357
    _command: ExprNS.Call,
358
    _context: Context,
359
  ): BoolValue {
360
    const obj = args[0];
362✔
361
    return {
362✔
362
      type: "bool",
363
      value: obj.type === "function" || obj.type === "closure" || obj.type === "builtin",
854✔
364
    };
365
  }
366

367
  @Validate(0, 1, "input", true)
368
  static async input(
53✔
369
    args: Value[],
370
    _source: string,
371
    _command: ExprNS.Call,
372
    context: Context,
373
  ): Promise<Value> {
374
    const prompt = args.length > 0 ? toPythonString(args[0]) : undefined;
2✔
375
    if (prompt !== undefined) {
2✔
376
      // Matches CPython: input(prompt) writes the prompt to stdout (no trailing newline)
377
      // before blocking on stdin.
378
      await displayOutput(context, prompt);
1✔
379
    }
380
    const userInput = await receiveInput(context, prompt);
2✔
381
    return { type: "string", value: userInput };
2✔
382
  }
383

384
  static async print(
385
    args: Value[],
386
    _source: string,
387
    _command: ExprNS.Call,
388
    context: Context,
389
  ): Promise<Value> {
390
    const output = args.map(arg => toPythonString(arg)).join(" ") + "\n";
825✔
391
    await displayOutput(context, output);
801✔
392
    return { type: "none" };
801✔
393
  }
394
  static str(
395
    args: Value[],
396
    _source: string,
397
    _command: ExprNS.Call,
398
    _context: Context,
399
  ): StringValue {
400
    if (args.length === 0) {
66✔
401
      return { type: "string", value: "" };
4✔
402
    }
403
    const obj = args[0];
62✔
404
    const result = toPythonString(obj);
62✔
405
    return { type: "string", value: result };
62✔
406
  }
407
  @Validate(1, 1, "repr", true)
408
  static repr(
53✔
409
    args: Value[],
410
    _source: string,
411
    _command: ExprNS.Call,
412
    _context: Context,
413
  ): StringValue {
414
    const obj = args[0];
49✔
415
    const result = toPythonString(obj, true);
49✔
416
    return { type: "string", value: result };
49✔
417
  }
418

419
  // Python's `breakpoint()` drops into a debugger; the CSE machine has no interactive debugger,
420
  // so as a value it is simply a no-op that yields `None`. A zero-arg call resolving to this
421
  // builtin is recognised by the CSE machine's step generator (interpreter.ts) as a breakpoint —
422
  // it records the step so the host's breakpoint-navigation controls can jump to it, then this
423
  // entry runs like any other builtin call. This mirrors the stepper's `breakpoint` entry
424
  // (src/conductor/stepper/builtins.ts), which does the analogous thing for the substitution model.
425
  static breakpoint(
426
    _args: Value[],
427
    _source: string,
428
    _command: ExprNS.Call,
429
    _context: Context,
430
  ): NoneValue {
431
    return { type: "none" };
59✔
432
  }
433

434
  // set_timeout(f, t): schedules f to be called with no arguments after t
435
  // milliseconds, without blocking the calling code (source-academy/py-slang#311).
436
  // Mirrors the sound_matrix module's existing JS set_timeout(f, t) (same
437
  // signature and units), moved into the language itself rather than staying
438
  // module-gated, since it's a capability of the evaluator, not of any one
439
  // module.
440
  //
441
  // Not yet implemented on the CSE machine: f must still be callable when the
442
  // real timer fires, which may be well after this evaluate() run has
443
  // finished and context.control/context.stash have been torn down — the CSE
444
  // machine has no way to resume a closure call from cold today (unlike
445
  // PVML's invokeValueAsync, built for sound_matrix's own set_timeout, or
446
  // py2js, where a compiled Python function already is a plain JS closure and
447
  // needs no re-entry machinery at all — see
448
  // src/engines/py2js/runtime.ts). Implemented only by py2js for now; this
449
  // entry exists so the name resolves consistently across engines.
450
  @Validate(2, 2, "set_timeout", true)
451
  static set_timeout(
53✔
452
    _args: Value[],
453
    _source: string,
454
    command: ExprNS.Call,
455
    context: Context,
456
  ): NoneValue {
NEW
457
    handleRuntimeError(
×
458
      context,
459
      new UserError("set_timeout is not yet supported by the CSE machine", command),
460
    );
461
  }
462

463
  // clear_all_timeout(): cancels every pending set_timeout callback scheduled
464
  // so far. See set_timeout above for why this is CSE-unsupported for now.
465
  @Validate(0, 0, "clear_all_timeout", true)
466
  static clear_all_timeout(
53✔
467
    _args: Value[],
468
    _source: string,
469
    command: ExprNS.Call,
470
    context: Context,
471
  ): NoneValue {
NEW
472
    handleRuntimeError(
×
473
      context,
474
      new UserError("clear_all_timeout is not yet supported by the CSE machine", command),
475
    );
476
  }
477
}
478
for (const builtin of Object.getOwnPropertyNames(MiscBuiltins)) {
53✔
479
  if (
1,590✔
480
    typeof MiscBuiltins[builtin as keyof typeof MiscBuiltins] === "function" &&
3,021✔
481
    !builtin.startsWith("_")
482
  ) {
483
    miscBuiltins.set(builtin, {
1,431✔
484
      type: "builtin",
485
      func: MiscBuiltins[builtin as keyof typeof MiscBuiltins] as BuiltinValue["func"],
486
      name: builtin,
487
      minArgs: minArgMap.get(builtin) || 0,
1,908✔
488
    });
489
  }
490
}
491

492
export default {
53✔
493
  name: GroupName.MISC,
494
  prelude: "",
495
  builtins: miscBuiltins,
496
};
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