Skip to content

Commit 7db6c04

Browse files
committed
feat: simplify adapter usage - must be passed explicitly
The adapters now trust that they have been registered and passed a correct-looking error/trace, rather than using heuristics to guess whether the provided input is "Skulpt-like" or "Pyodide-like"
1 parent 17328e6 commit 7db6c04

8 files changed

Lines changed: 71 additions & 34 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Todo:
44
- Set up automated testing and publishing through GitHub Actions
55
- Accessibility of output HTML
66

7-
A small, runtime-agnostic, library that explains Python error messages in a friendlier way, inspired by [p5.js's Friendly Error System](https://p5js.org/contribute/friendly_error_system/).
7+
A small, Pyodide and Skulpt-focused, library that explains Python error messages in a friendlier way, inspired by [p5.js's Friendly Error System](https://p5js.org/contribute/friendly_error_system/).
88

99
It can be used in browser-based editors (like RPF's [Code Editor web component](https://github.com/RaspberryPiFoundation/editor-ui)) or any environment that executes Python code through Skulpt or Pyodide.
1010

@@ -35,7 +35,8 @@ registerAdapter("pyodide", pyodideAdapter);
3535
// later, when you have an error string and some code:
3636
const result = friendlyExplain({
3737
error: rawTracebackString,
38-
code: editorCode
38+
code: editorCode,
39+
runtime: "skulpt" // or "pyodide", matching the adapter/runtime that produced the traceback
3940
});
4041

4142
// result.html is a ready-made snippet

docs/index.html

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ <h1>Python Friendly Error Messages - Demo</h1>
5454
: './vendor/python-friendly-error-messages/index.browser.js';
5555
const libPath = cacheBust ? `${libPathBase}?cb=${encodeURIComponent(cacheBust)}` : libPathBase;
5656

57-
const { friendlyExplain, loadCopydeck, registerAdapter, loadCopydeckFor, skulptAdapter, pyodideAdapter } = await import(libPath);
57+
const { friendlyExplain, loadCopydeckFor, skulptAdapter, pyodideAdapter } = await import(libPath);
5858

5959
async function initDemo() {
6060
const container = document.getElementById('demo-container');
@@ -92,13 +92,25 @@ <h1>Python Friendly Error Messages - Demo</h1>
9292

9393
try {
9494
await loadCopydeckFor('en');
95-
registerAdapter('skulpt', skulptAdapter);
96-
registerAdapter('pyodide', pyodideAdapter);
95+
96+
const adaptersByRuntime = {
97+
skulpt: skulptAdapter,
98+
pyodide: pyodideAdapter
99+
};
97100

98101
const processedExamples = examples.map((example) => {
99102
try {
103+
const runtimeAdapter = adaptersByRuntime[example.runtime];
104+
if (!runtimeAdapter) {
105+
throw new Error(`No adapter configured for runtime \"${example.runtime}\".`);
106+
}
107+
const parsedTrace = runtimeAdapter(example.trace, example.code);
108+
if (!parsedTrace) {
109+
throw new Error(`Could not parse trace for runtime \"${example.runtime}\".`);
110+
}
111+
100112
const result = friendlyExplain({
101-
error: example.trace,
113+
error: parsedTrace,
102114
code: example.code
103115
});
104116

src/adapters/pyodide.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ export const pyodideAdapter: AdapterFn = (raw, code) => {
2525
const q = (message || "").match(/["']([^"']+)["']/);
2626
if (q) name = q[1];
2727

28+
// Parse-quality gate:
29+
// We only accept this adapter output if we recovered at least one structured
30+
// signal that is useful for explanation selection or UI context:
31+
// - type: error class from the final traceback line (for copydeck matching)
32+
// - line: source location in user code (for codeLine/context extraction)
33+
// - name: quoted symbol from the message (helpful for NameError/KeyError/etc.)
34+
//
35+
// If none are present, the input is not parseable enough for this adapter,
36+
// so we return null and let the caller handle that failure explicitly.
37+
const hasStructuredSignal = Boolean(type || line || name);
38+
if (!hasStructuredSignal) return null;
39+
2840
const t: Trace = {
2941
type,
3042
message,

src/adapters/skulpt.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,6 @@ const lastLineTypeMessage = (raw: string) => {
88
};
99

1010
export const skulptAdapter: AdapterFn = (raw, code) => {
11-
if (!/skulpt/i.test(raw) && !/Traceback/i.test(raw)) {
12-
// still try; skulpt often includes "on line X of", etc.
13-
}
1411
const { type, message, tail, lines } = lastLineTypeMessage(raw);
1512
let file: string | undefined, line: number | undefined, col: number | undefined;
1613

@@ -33,6 +30,18 @@ export const skulptAdapter: AdapterFn = (raw, code) => {
3330
const q = (message || "").match(/["']([^"']+)["']/);
3431
if (q) name = q[1];
3532

33+
// Parse-quality gate:
34+
// We only accept this adapter output if we recovered at least one structured
35+
// signal that is useful for explanation selection or UI context:
36+
// - type: error class from the final traceback line (for copydeck matching)
37+
// - line: source location in user code (for codeLine/context extraction)
38+
// - name: quoted symbol from the message (helpful for NameError/KeyError/etc.)
39+
//
40+
// If none are present, the input is not parseable enough for this adapter,
41+
// so we return null and let the caller handle that failure explicitly.
42+
const hasStructuredSignal = Boolean(type || line || name);
43+
if (!hasStructuredSignal) return null;
44+
3645
const t: Trace = {
3746
type,
3847
message,

src/engine.ts

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,28 +17,24 @@ const getUiString = (key: keyof NonNullable<CopyDeck["ui"]>, fallback: string):
1717
export const registerAdapter = (name: string, fn: (raw: string, code?: string) => Trace | null) =>
1818
(state.adapters[name] = fn);
1919

20-
const coerceTrace = (input: string | Error | Trace, code?: string): Trace => {
20+
const coerceTrace = (input: string | Error | Trace, code?: string, runtime?: string): Trace => {
2121
if ((input as Trace).raw !== undefined) return input as Trace;
22-
const raw = typeof input === "string" ? input : String((input as Error).stack || (input as Error).message || input);
23-
// try adapters in registration order
24-
for (const key of Object.keys(state.adapters)) {
25-
const t = state.adapters[key](raw, code);
26-
if (t) return t;
22+
23+
if (!runtime) {
24+
throw new Error("Runtime is required when error is a string or Error. Pass opts.runtime or parse with an adapter first.");
2725
}
28-
// generic fallback
29-
const lines = raw.trim().split(/\r?\n/).filter(Boolean);
30-
const tail = lines[lines.length - 1] || "";
31-
const m = tail.match(/^(\w+Error)\s*:\s*(.*)$/);
32-
const t: Trace = {
33-
type: m ? m[1] : null,
34-
message: m ? m[2] : tail,
35-
raw,
36-
runtime: "unknown"
37-
};
38-
if (code) {
39-
t.codeLine = code.split(/\r?\n/)[(t.line || 1) - 1]?.trim();
26+
27+
const adapter = state.adapters[runtime];
28+
if (!adapter) {
29+
throw new Error(`No adapter registered for runtime \"${runtime}\".`);
30+
}
31+
32+
const raw = typeof input === "string" ? input : String((input as Error).stack || (input as Error).message || input);
33+
const parsed = adapter(raw, code);
34+
if (!parsed) {
35+
throw new Error(`Could not parse error for runtime \"${runtime}\".`);
4036
}
41-
return t;
37+
return parsed;
4238
};
4339

4440
const pickVariant = (trace: Trace, code: string | undefined) => {
@@ -124,7 +120,7 @@ export const friendlyExplain = (opts: ExplainOptions): ExplainResult => {
124120
if (!state.copy) throw new Error("Copydeck not loaded");
125121
const code = opts.code;
126122

127-
const trace = coerceTrace(opts.error, code);
123+
const trace = coerceTrace(opts.error, code, opts.runtime);
128124
if (code && trace.line && !trace.codeLine) {
129125
const lines = code.split(/\r?\n/);
130126
trace.codeLine = lines[trace.line - 1]?.trim();

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type ExplainOptions = {
2121
error: string | Error | Trace;
2222
code?: string;
2323
locale?: string;
24+
runtime?: string;
2425
};
2526

2627
export type ExplainResult = {

tests/engine.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ describe("engine", () => {
6969
const raw = `Traceback (most recent call last):
7070
File "main.py", line 2, in <module>
7171
NameError: name 'kittens' is not defined`;
72-
const res = friendlyExplain({ error: raw, code });
72+
const res = friendlyExplain({ error: raw, code, runtime: "skulpt" });
7373
expect(res.trace.type).toBe("NameError");
7474
expect(res.title).toMatch(/name/i);
7575
expect(res.summary).toMatch(/kittens/);
@@ -82,7 +82,7 @@ NameError: name 'kittens' is not defined`;
8282
const raw = `Traceback (most recent call last):
8383
File "main.py", line 1
8484
SyntaxError: invalid syntax`;
85-
const res = friendlyExplain({ error: raw, code });
85+
const res = friendlyExplain({ error: raw, code, runtime: "skulpt" });
8686
expect(res.trace.type).toBe("SyntaxError");
8787
expect(res.title).toMatch(/colon/i);
8888
expect(res.patch).toMatch(/:\s*$/);
@@ -93,7 +93,7 @@ SyntaxError: invalid syntax`;
9393
const raw = `Traceback (most recent call last):
9494
File "main.py", line 2
9595
AttributeError: 'list' object has no attribute 'push'`;
96-
const res = friendlyExplain({ error: raw, code });
96+
const res = friendlyExplain({ error: raw, code, runtime: "skulpt" });
9797
expect(res.trace.type).toBe("AttributeError");
9898
expect(res.patch).toContain(".append(");
9999
});

tests/loaders.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ describe("loadCopydeckFor", () => {
2525
await loadCopydeckFor("en-GB");
2626
registerAdapter("skulpt", skulptAdapter);
2727

28-
const res = friendlyExplain({ error: "TypeError: bad", code: "" });
28+
const res = friendlyExplain({
29+
error: { type: "TypeError", message: "bad", raw: "TypeError: bad", runtime: "unknown" },
30+
code: ""
31+
});
2932
expect(res.title).toBe("Python error");
3033
expect((globalThis.fetch as any).mock.calls[0][0]).toMatch(/copydecks\/en-GB\/copydeck\.json/);
3134
expect((globalThis.fetch as any).mock.calls[1][0]).toMatch(/copydecks\/en\/copydeck\.json/);
@@ -41,7 +44,10 @@ describe("loadCopydeckFor", () => {
4144
it("still supports manual loadCopydeck for tests without fetch", () => {
4245
loadCopydeck(minimalDeck as any);
4346
registerAdapter("skulpt", skulptAdapter);
44-
const res = friendlyExplain({ error: "NameError: name 'x' is not defined", code: "" });
47+
const res = friendlyExplain({
48+
error: { type: "NameError", message: "name 'x' is not defined", raw: "NameError: name 'x' is not defined", runtime: "unknown" },
49+
code: ""
50+
});
4551
expect(res.title).toBe("Python error");
4652
});
4753
});

0 commit comments

Comments
 (0)