Skip to content

Commit 332d3fa

Browse files
authored
Error line numbers coming out with invalid values (#11234)
* Working inside cell hash provider * Add more unit tests * Add news entry * Fix sonar warnings
1 parent 8672b5e commit 332d3fa

File tree

7 files changed

+244
-66
lines changed

7 files changed

+244
-66
lines changed

news/2 Fixes/10708.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Make error links in exception tracebacks support multiple cells in the stack and extra spaces.

src/client/datascience/editor-integration/cellhashprovider.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33
'use strict';
4+
import type { KernelMessage } from '@jupyterlab/services';
45
import * as hashjs from 'hash.js';
56
import { inject, injectable, multiInject, optional } from 'inversify';
7+
import stripAnsi from 'strip-ansi';
68
import { Event, EventEmitter, Position, Range, TextDocumentChangeEvent, TextDocumentContentChangeEvent } from 'vscode';
79

810
import { splitMultilineString } from '../../../datascience-ui/common';
@@ -23,12 +25,18 @@ import {
2325
INotebookExecutionLogger
2426
} from '../types';
2527

28+
// tslint:disable-next-line:no-require-imports no-var-requires
29+
const _escapeRegExp = require('lodash/escapeRegExp') as typeof import('lodash/escapeRegExp'); // NOSONAR
30+
const LineNumberMatchRegex = /(;32m[ ->]*?)(\d+)/g;
31+
2632
interface IRangedCellHash extends ICellHash {
2733
code: string;
2834
startOffset: number;
2935
endOffset: number;
3036
deleted: boolean;
3137
realCode: string;
38+
trimmedRightCode: string;
39+
firstNonBlankLineIndex: number; // zero based. First non blank line of the real code.
3240
}
3341

3442
// This class provides hashes for debugging jupyter cells. Call getHashes just before starting debugging to compute all of the
@@ -45,6 +53,7 @@ export class CellHashProvider implements ICellHashProvider, INotebookExecutionLo
4553
private executionCount: number = 0;
4654
private hashes: Map<string, IRangedCellHash[]> = new Map<string, IRangedCellHash[]>();
4755
private updateEventEmitter: EventEmitter<void> = new EventEmitter<void>();
56+
private traceBackRegexes = new Map<string, RegExp>();
4857

4958
constructor(
5059
@inject(IDocumentManager) private documentManager: IDocumentManager,
@@ -59,6 +68,7 @@ export class CellHashProvider implements ICellHashProvider, INotebookExecutionLo
5968

6069
public dispose() {
6170
this.hashes.clear();
71+
this.traceBackRegexes.clear();
6272
}
6373

6474
public get updated(): Event<void> {
@@ -83,6 +93,7 @@ export class CellHashProvider implements ICellHashProvider, INotebookExecutionLo
8393

8494
public onKernelRestarted() {
8595
this.hashes.clear();
96+
this.traceBackRegexes.clear();
8697
this.executionCount = 0;
8798
this.updateEventEmitter.fire();
8899
}
@@ -112,6 +123,21 @@ export class CellHashProvider implements ICellHashProvider, INotebookExecutionLo
112123
noop();
113124
}
114125

126+
public preHandleIOPub(msg: KernelMessage.IIOPubMessage): KernelMessage.IIOPubMessage {
127+
// When an error message comes, rewrite the traceback so we can jump back to the correct
128+
// cell. For now this only works with the interactive window
129+
if (msg.header.msg_type === 'error') {
130+
return {
131+
...msg,
132+
content: {
133+
...msg.content,
134+
traceback: this.modifyTraceback(msg as KernelMessage.IErrorMsg) // NOSONAR
135+
}
136+
};
137+
}
138+
return msg;
139+
}
140+
115141
public extractExecutableLines(cell: ICell): string[] {
116142
const cellMatcher = new CellMatcher(this.configService.getSettings(getCellResource(cell)).datascience);
117143
const lines = splitMultilineString(cell.data.source);
@@ -144,6 +170,12 @@ export class CellHashProvider implements ICellHashProvider, INotebookExecutionLo
144170
const line = doc.lineAt(trueStartLine);
145171
const endLine = doc.lineAt(Math.min(trueStartLine + stripped.length - 1, doc.lineCount - 1));
146172

173+
// Find the first non blank line
174+
let firstNonBlankIndex = 0;
175+
while (firstNonBlankIndex < stripped.length && stripped[firstNonBlankIndex].trim().length === 0) {
176+
firstNonBlankIndex += 1;
177+
}
178+
147179
// Use the original values however to track edits. This is what we need
148180
// to move around
149181
const startOffset = doc.offsetAt(new Position(cell.line, 0));
@@ -180,11 +212,13 @@ export class CellHashProvider implements ICellHashProvider, INotebookExecutionLo
180212
hash: hashjs.sha1().update(hashedCode).digest('hex').substr(0, 12),
181213
line: line.lineNumber + 1,
182214
endLine: endLine.lineNumber + 1,
215+
firstNonBlankLineIndex: firstNonBlankIndex + trueStartLine,
183216
executionCount: expectedCount,
184217
startOffset,
185218
endOffset,
186219
deleted: false,
187220
code: hashedCode,
221+
trimmedRightCode: stripped.map((s) => s.replace(/[ \t\r]+\n$/g, '\n')).join(''),
188222
realCode,
189223
runtimeLine,
190224
id: cell.id
@@ -217,6 +251,15 @@ export class CellHashProvider implements ICellHashProvider, INotebookExecutionLo
217251
}
218252
this.hashes.set(cell.file, list);
219253

254+
// Save a regex to find this file later when looking for
255+
// exceptions in output
256+
if (!this.traceBackRegexes.has(cell.file)) {
257+
const fileDisplayName = this.fileSystem.getDisplayName(cell.file);
258+
const escaped = _escapeRegExp(fileDisplayName);
259+
const fileMatchRegex = new RegExp(`\\[.*?;32m${escaped}`);
260+
this.traceBackRegexes.set(cell.file, fileMatchRegex);
261+
}
262+
220263
// Tell listeners we have new hashes.
221264
if (this.listeners) {
222265
const hashes = this.getHashes();
@@ -306,4 +349,60 @@ export class CellHashProvider implements ICellHashProvider, INotebookExecutionLo
306349
// No breakpoint necessary, start on the first line
307350
return 1;
308351
}
352+
353+
// This function will modify a traceback from an error message.
354+
// Tracebacks take a form like so:
355+
// "---------------------------------------------------------------------------"
356+
// "ZeroDivisionError Traceback (most recent call last)"
357+
// "d:\Training\SnakePython\foo.py in <module>\n 1 print('some more')\n ----> 2 cause_error()\n "
358+
// "d:\Training\SnakePython\foo.py in cause_error()\n 3 print('error')\n  4 print('now')\n ----> 5 print( 1 / 0)\n "
359+
// "ZeroDivisionError: division by zero"
360+
// Each item in the array being a stack frame.
361+
private modifyTraceback(msg: KernelMessage.IErrorMsg): string[] {
362+
// Do one frame at a time.
363+
return msg.content.traceback ? msg.content.traceback.map(this.modifyTracebackFrame.bind(this)) : [];
364+
}
365+
366+
private findCellOffset(hashes: IRangedCellHash[] | undefined, codeLines: string): number | undefined {
367+
if (hashes) {
368+
// Go through all cell code looking for these code lines exactly
369+
// (although with right side trimmed as that's what a stack trace does)
370+
for (const hash of hashes) {
371+
const index = hash.trimmedRightCode.indexOf(codeLines);
372+
if (index >= 0) {
373+
// Jupyter isn't counting blank lines at the top so use our
374+
// first non blank line
375+
return hash.firstNonBlankLineIndex;
376+
}
377+
}
378+
}
379+
// No hash found
380+
return undefined;
381+
}
382+
383+
private modifyTracebackFrame(traceFrame: string): string {
384+
// See if this item matches any of our cell files
385+
const regexes = [...this.traceBackRegexes.entries()];
386+
const match = regexes.find((e) => e[1].test(traceFrame));
387+
if (match) {
388+
// We have a match, pull out the source lines
389+
let sourceLines = '';
390+
const regex = /(;32m[ ->]*?)(\d+)(.*)/g;
391+
for (let l = regex.exec(traceFrame); l && l.length > 3; l = regex.exec(traceFrame)) {
392+
const newLine = stripAnsi(l[3]).substr(1); // Seem to have a space on the front
393+
sourceLines = `${sourceLines}${newLine}\n`;
394+
}
395+
396+
// Now attempt to find a cell that matches these source lines
397+
const offset = this.findCellOffset(this.hashes.get(match[0]), sourceLines);
398+
if (offset !== undefined) {
399+
return traceFrame.replace(LineNumberMatchRegex, (_s, prefix, num) => {
400+
const n = parseInt(num, 10);
401+
const newLine = offset + n - 1;
402+
return `${prefix}<a href='file://${match[0]}?line=${newLine}'>${newLine + 1}</a>`;
403+
});
404+
}
405+
}
406+
return traceFrame;
407+
}
309408
}

src/client/datascience/jupyter/jupyterNotebook.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737
InterruptResult,
3838
KernelSocketInformation
3939
} from '../types';
40-
import { expandWorkingDir, modifyTraceback } from './jupyterUtils';
40+
import { expandWorkingDir } from './jupyterUtils';
4141
import { LiveKernelModel } from './kernels/types';
4242

4343
// tslint:disable-next-line: no-require-imports
@@ -940,6 +940,9 @@ export class JupyterNotebookBase implements INotebook {
940940
// tslint:disable-next-line: no-any
941941
let result: Promise<any> = Promise.resolve();
942942

943+
// Let our loggers get a first crack at the message. They may change it
944+
this.getLoggers().forEach((f) => (msg = f.preHandleIOPub ? f.preHandleIOPub(msg) : msg));
945+
943946
// tslint:disable-next-line:no-require-imports
944947
const jupyterLab = require('@jupyterlab/services') as typeof import('@jupyterlab/services');
945948

@@ -1366,7 +1369,7 @@ export class JupyterNotebookBase implements INotebook {
13661369
output_type: 'error',
13671370
ename: msg.content.ename,
13681371
evalue: msg.content.evalue,
1369-
traceback: modifyTraceback(cell.file, this.fs.getDisplayName(cell.file), cell.line, msg.content.traceback)
1372+
traceback: msg.content.traceback
13701373
};
13711374
this.addToCellData(cell, output, clearState);
13721375
cell.state = CellState.error;

src/client/datascience/jupyter/jupyterUtils.ts

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,9 @@ import { IWorkspaceService } from '../../common/application/types';
1010
import { IDataScienceSettings } from '../../common/types';
1111
import { noop } from '../../common/utils/misc';
1212
import { SystemVariables } from '../../common/variables/systemVariables';
13-
import { Identifiers } from '../constants';
1413
import { getJupyterConnectionDisplayName } from '../jupyter/jupyterConnection';
1514
import { IConnection } from '../types';
1615

17-
// tslint:disable-next-line:no-require-imports no-var-requires
18-
const _escapeRegExp = require('lodash/escapeRegExp') as typeof import('lodash/escapeRegExp');
19-
2016
export function expandWorkingDir(
2117
workingDir: string | undefined,
2218
launchingFile: string,
@@ -62,44 +58,3 @@ export function createRemoteConnectionInfo(uri: string, settings: IDataScienceSe
6258
dispose: noop
6359
};
6460
}
65-
66-
const LineMatchRegex = /(;32m[ ->]*?)(\d+)/g;
67-
const IPythonMatchRegex = /(<ipython-input.*?>)/g;
68-
69-
function modifyLineNumbers(entry: string, file: string, startLine: number): string {
70-
return entry.replace(LineMatchRegex, (_s, prefix, num) => {
71-
const n = parseInt(num, 10);
72-
const newLine = startLine + n;
73-
return `${prefix}<a href='file://${file}?line=${newLine}'>${newLine + 1}</a>`;
74-
});
75-
}
76-
77-
function modifyTracebackEntry(
78-
fileMatchRegex: RegExp,
79-
file: string,
80-
fileDisplayName: string,
81-
startLine: number,
82-
entry: string
83-
): string {
84-
if (fileMatchRegex.test(entry)) {
85-
return modifyLineNumbers(entry, file, startLine);
86-
} else if (IPythonMatchRegex.test(entry)) {
87-
const ipythonReplaced = entry.replace(IPythonMatchRegex, fileDisplayName);
88-
return modifyLineNumbers(ipythonReplaced, file, startLine);
89-
}
90-
return entry;
91-
}
92-
93-
export function modifyTraceback(
94-
file: string,
95-
fileDisplayName: string,
96-
startLine: number,
97-
traceback: string[]
98-
): string[] {
99-
if (file && file !== Identifiers.EmptyFileName) {
100-
const escaped = _escapeRegExp(fileDisplayName);
101-
const fileMatchRegex = new RegExp(`\\[.*?;32m${escaped}`);
102-
return traceback.map(modifyTracebackEntry.bind(undefined, fileMatchRegex, file, fileDisplayName, startLine));
103-
}
104-
return traceback;
105-
}

src/client/datascience/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ export interface INotebookExecutionLogger {
249249
preExecute(cell: ICell, silent: boolean): Promise<void>;
250250
postExecute(cell: ICell, silent: boolean): Promise<void>;
251251
onKernelRestarted(): void;
252+
preHandleIOPub?(msg: KernelMessage.IIOPubMessage): KernelMessage.IIOPubMessage;
252253
}
253254

254255
export const IGatherProvider = Symbol('IGatherProvider');

0 commit comments

Comments
 (0)