Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Start with the owning directory and its colocated tests before searching broadly
| Environment snapshots & `requirements.txt` generation | `src/notebooks/deepnote/snapshots/` |
| Database integrations (credentials, env refresh, webview) | `src/notebooks/deepnote/integrations/`, `src/webviews/webview-side/integrations/` |
| Kernel selection, SQL LSP, server startup | `src/kernels/deepnote/` |
| Deepnote Cloud API token, sync and publishing | `src/notebooks/deepnote/cloud/` |
| Shared Deepnote types & platform config | `src/platform/deepnote/`, `src/platform/notebooks/deepnote/` |
| Core serializer architecture & data flow | `specs/architecture.md` |
| Kernel management internals | `specs/DEBUGGING_KERNEL_MANAGEMENT.md`, `specs/DEEPNOTE_KERNEL_IMPLEMENTATION.md` |
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ Open the Command Palette (`Cmd+Shift+P` or `Ctrl+Shift+P`) and type `Deepnote` t
| `Deepnote: Manage Integrations` | Configure database connections and credentials |
| `Deepnote: New Project` | Create a new Deepnote project |
| `Deepnote: Import Notebook` | Import an existing notebook into your project |
| `Deepnote: Set API Token` | Save a Deepnote API token to `.env` for syncing and publishing |
| `Deepnote: Clear API Token` | Remove the saved Deepnote API token |
| `Notebook: Select Notebook Kernel` | Select or switch kernels within your notebook |
| `Notebook: Change Cell Language` | Change the language of the cell currently in focus |
| `Deepnote: Enable Snapshots` | Enable snapshot mode for the current workspace |
Expand Down
12 changes: 12 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,18 @@
"category": "Deepnote",
"icon": "$(globe)"
},
{
"command": "deepnote.setApiToken",
"title": "%deepnote.commands.setApiToken.title%",
"category": "Deepnote",
"enablement": "isWorkspaceTrusted && !deepnote.webExtension"
},
{
"command": "deepnote.clearApiToken",
"title": "%deepnote.commands.clearApiToken.title%",
"category": "Deepnote",
"enablement": "isWorkspaceTrusted && !deepnote.webExtension"
},
{
"command": "deepnote.newProject",
"title": "%deepnote.commands.newProject.title%",
Expand Down
2 changes: 2 additions & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@
"deepnote.commands.manageIntegrations.title": "Manage Integrations",
"deepnote.commands.addExistingIntegration.title": "Add Existing Integration",
"deepnote.commands.authenticateIntegration.title": "Authenticate Integration",
"deepnote.commands.setApiToken.title": "Set API Token",
"deepnote.commands.clearApiToken.title": "Clear API Token",
"deepnote.commands.newProject.title": "New Project",
"deepnote.commands.importNotebook.title": "Import Notebook",
"deepnote.commands.importJupyterNotebook.title": "Import Jupyter Notebook",
Expand Down
2 changes: 2 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,4 +210,6 @@ export interface ICommandNameArgumentTypeMapping {
[DSCommands.DuplicateNotebook]: [];
[DSCommands.AddNotebookToProject]: [];
[DSCommands.OpenInDeepnote]: [];
[DSCommands.SetApiToken]: [Uri | undefined];
[DSCommands.ClearApiToken]: [];
}
39 changes: 39 additions & 0 deletions src/notebooks/deepnote/cloud/cloudErrors.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ApiError } from '@deepnote/cloud-sync';
import { commands, Uri, window } from 'vscode';

import { Commands } from '../../../platform/common/constants';
import { DeepnoteCloud } from '../../../platform/common/utils/localize';
import { logger } from '../../../platform/logging';

const UNAUTHORIZED_STATUS = 401;

/**
* Whether Deepnote answered 401, meaning the token is missing, invalid or revoked. A 403 is not matched: the token was
* accepted, but the request is not allowed.
*/
export function isRejectedTokenError(error: unknown): boolean {
return error instanceof ApiError && error.statusCode === UNAUTHORIZED_STATUS;
}

/**
* Logs a failed Deepnote Cloud call and tells the user. A rejected token offers "Set API Token" for `resource` and
* resolves once that prompt is dismissed; any other failure shows `<message>: <detail>` and resolves immediately.
*/
export async function showCloudError(message: string, error: unknown, resource?: Uri): Promise<void> {
logger.error(message, error);

if (isRejectedTokenError(error)) {
const choice = await window.showErrorMessage(
DeepnoteCloud.apiTokenRejectedForRequest,
DeepnoteCloud.setApiToken
);
if (choice === DeepnoteCloud.setApiToken) {
await commands.executeCommand(Commands.SetApiToken, resource);
}

return;
}

const detail = error instanceof Error ? error.message : String(error);
void window.showErrorMessage(DeepnoteCloud.errorWithDetail(message, detail));
}
97 changes: 97 additions & 0 deletions src/notebooks/deepnote/cloud/cloudErrors.node.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { ApiError } from '@deepnote/cloud-sync';
import { assert } from 'chai';
import { anything, verify, when } from 'ts-mockito';
import { Uri } from 'vscode';

import { DeepnoteCloud } from '../../../platform/common/utils/localize';
import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock';
import { isRejectedTokenError, showCloudError } from './cloudErrors.node';

suite('cloudErrors', () => {
setup(() => {
resetVSCodeMocks();
when(mockedVSCodeNamespaces.commands.executeCommand(anything(), anything())).thenResolve(undefined);
});

suite('isRejectedTokenError', () => {
test('is true for an ApiError with status 401', () => {
assert.isTrue(isRejectedTokenError(new ApiError(401, 'Unauthorized')));
});

test('is false for an ApiError with another status, including 403', () => {
assert.isFalse(isRejectedTokenError(new ApiError(403, 'Forbidden')));
assert.isFalse(isRejectedTokenError(new ApiError(500, 'Boom')));
});

test('is false for errors that are not an ApiError', () => {
assert.isFalse(isRejectedTokenError(new Error('offline')));
assert.isFalse(isRejectedTokenError({ statusCode: 401 }));
assert.isFalse(isRejectedTokenError('401'));
assert.isFalse(isRejectedTokenError(undefined));
});
});

suite('showCloudError', () => {
test('offers Set API Token for a rejected token, and runs the command for the resource when chosen', async () => {
const resource = Uri.file('/r');
when(
mockedVSCodeNamespaces.window.showErrorMessage(
DeepnoteCloud.apiTokenRejectedForRequest,
DeepnoteCloud.setApiToken
)
).thenResolve(DeepnoteCloud.setApiToken as any);

await showCloudError(
'Sync failed',
new ApiError(401, 'Authentication failed. Please check your API token.'),
resource
);

verify(
mockedVSCodeNamespaces.window.showErrorMessage(
DeepnoteCloud.apiTokenRejectedForRequest,
DeepnoteCloud.setApiToken
)
).once();
verify(mockedVSCodeNamespaces.commands.executeCommand('deepnote.setApiToken', resource)).once();
verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).never();
});

test('does not run the command when the rejected-token prompt is dismissed', async () => {
await showCloudError('Sync failed', new ApiError(401, 'Unauthorized'), Uri.file('/r'));

verify(mockedVSCodeNamespaces.commands.executeCommand(anything(), anything())).never();
verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).never();
});

test('shows the server message without a button for a 403', async () => {
await showCloudError('Publish failed', new ApiError(403, 'Insufficient permissions to publish this app.'));

verify(
mockedVSCodeNamespaces.window.showErrorMessage(
'Publish failed: Insufficient permissions to publish this app.'
)
).once();
verify(mockedVSCodeNamespaces.window.showErrorMessage(anything(), anything())).never();
});

test('shows the message of any other ApiError', async () => {
await showCloudError('Sync failed', new ApiError(500, 'Boom'));

verify(mockedVSCodeNamespaces.window.showErrorMessage('Sync failed: Boom')).once();
});

test('shows the message of a plain Error without a button', async () => {
await showCloudError('Sync failed', new Error('offline'));

verify(mockedVSCodeNamespaces.window.showErrorMessage('Sync failed: offline')).once();
verify(mockedVSCodeNamespaces.window.showErrorMessage(anything(), anything())).never();
});

test('stringifies a thrown value that is not an Error', async () => {
await showCloudError('Sync failed', 'timed out');

verify(mockedVSCodeNamespaces.window.showErrorMessage('Sync failed: timed out')).once();
});
});
});
Loading
Loading