Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 2 additions & 4 deletions docs/scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ Deepnote makes it easy to take your explorations to production. For example:

![Scheduling notifications.jpg](../assets/docs/5vnhJjTRfuHyBA0aJTNC.jpg)

Each notebook in a project can have its own schedule.

### How to know if your scheduled notebook ran successfully

If an exception is raised during the run, the run is considered failed; otherwise, it's successful. Please note that if an error is raised in a block, the subsequent blocks are not executed. Every run automatically creates a [run snapshot](/docs/run-snapshots) — an immutable record of the notebook's state after execution. You can review snapshots of successful and failed runs from the Runs sidebar or the project logs.
Expand All @@ -42,7 +44,3 @@ Scheduled notebooks can use integrations that rely on federated (OAuth) authenti
### Alerts

You can use the scheduled notebook to periodically check the consistency of your data and use the notification system to alert you when the notebook fails (i.e. the consistency check fails).

### Current limitations

- Only one scheduled notebook per project is supported. If you need to schedule more, we recommend splitting the work across multiple projects.
4 changes: 2 additions & 2 deletions examples/local-runner/run-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ frontends can still send raw cron.

Scheduling creates the cloud notebook if necessary but does not run it immediately. Recurring runs
use the input values stored in Deepnote; when scheduling creates the notebook, those are the
defaults committed in the `.deepnote` file. Deepnote allows one scheduled notebook per project, so
saving again updates that project schedule.
defaults committed in the `.deepnote` file. Each notebook has its own schedule, so saving again
updates this notebook's schedule.

The scheduler remains available while a cloud run is active. The local-runner library coordinates
the first create-if-missing operation, so custom frontends can safely offer the same concurrency
Expand Down
16 changes: 11 additions & 5 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -628,14 +628,14 @@ select a non-default API origin.

### `schedule <path>`

Create or update a recurring notebook run in Deepnote Cloud. This does not run the notebook
Create, update, or remove a recurring notebook run in Deepnote Cloud. This does not run the notebook
immediately. If the local project is missing in Deepnote, the CLI creates it without opening a browser first.

```bash
deepnote schedule report.deepnote --daily --at 09:00
```

Choose exactly one frequency:
Choose exactly one frequency, or pass `--remove`:

| Option | Description | Default |
| ----------------------- | ----------------------------------------------- | -------------------------- |
Expand All @@ -651,11 +651,14 @@ Choose exactly one frequency:
| `--url <url>` | Deepnote API base URL | `https://api.deepnote.com` |
| `--no-create` | Fail rather than create a missing project | `false` |
| `--open` | Open the scheduled notebook after configuration | `false` |
| `--remove` | Remove the notebook's schedule | `false` |
| `-o, --output json` | Print machine-readable JSON | text |

Deepnote supports one scheduled notebook per project. Re-running this command updates that project
schedule, including when a different notebook is selected. Scheduling availability depends on the
workspace plan.
Each notebook has its own schedule. Running the command again updates that notebook's schedule;
other notebooks keep theirs. Scheduling availability depends on the workspace plan.

`--remove` deletes the notebook's schedule. It never creates a project, works on any plan, and exits
`0` when there is nothing to remove.

Without `--at`, a schedule fires at the time it was created — hour and minute for daily, weekly and
monthly, the minute alone for `--hourly`. Deepnote's scheduling UI defaults new schedules the same
Expand All @@ -676,6 +679,9 @@ deepnote schedule report.deepnote --daily --open

# Machine-readable output
deepnote schedule report.deepnote --hourly -o json

# Remove one notebook's schedule
deepnote schedule project.deepnote --notebook "Weekly review" --remove
```

### `sync [dir]`
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ describe('CLI', () => {
'--url <url>',
'--no-create',
'--open',
'--remove',
'-o, --output <format>',
])
)
Expand Down Expand Up @@ -176,6 +177,7 @@ describe('CLI', () => {
return 0
fi`)
expect(output).toContain('--storage-mode --timeout --push --yes --url')
expect(output).toContain('--no-create --open --remove -o --output')
consoleSpy.mockRestore()
})

Expand All @@ -196,6 +198,7 @@ describe('CLI', () => {
)
expect(output).toContain("'--push[Push the local .deepnote blocks to the Deepnote notebook before running]'")
expect(output).toContain("'--yes[Skip the --push confirmation prompt]'")
expect(output).toContain("'--remove[Remove the notebook schedule]'")
consoleSpy.mockRestore()
})

Expand All @@ -219,6 +222,9 @@ describe('CLI', () => {
expect(output).toContain(
"complete -c deepnote -n '__fish_seen_subcommand_from run' -l yes -d 'Skip the --push confirmation prompt'"
)
expect(output).toContain(
"complete -c deepnote -n '__fish_seen_subcommand_from schedule' -l remove -d 'Remove the notebook schedule'"
)
consoleSpy.mockRestore()
})

Expand Down
14 changes: 10 additions & 4 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,14 +472,16 @@ ${c.bold('Exit Codes:')}
.option('--url <url>', 'Deepnote API base URL', DEFAULT_API_URL)
.option('--no-create', 'Do not create the project in Deepnote Cloud when it is missing')
.option('--open', 'Open the scheduled notebook in your default browser')
.option('--remove', "Remove the notebook's schedule")
.option('-o, --output <format>', 'Output format: json', createFormatValidator(['json']))
.addHelpText('after', () => {
const c = getChalk()
return `
${c.bold('Description:')}
Creates or updates the recurring Deepnote Cloud schedule for this project.
Creates or updates the recurring Deepnote Cloud schedule for the selected notebook.
Other notebooks' schedules are not affected.
If the local project is not in Deepnote yet, it is created without running it.
A project has one schedule, so scheduling another notebook updates that schedule.
Use --remove to delete the schedule instead.

${c.bold('Examples:')}
${c.dim('# Run every day at 09:00 in your local timezone')}
Expand All @@ -497,12 +499,16 @@ ${c.bold('Examples:')}
${c.dim('# Schedule and open the cloud notebook')}
$ deepnote schedule report.deepnote --daily --open

${c.dim("# Remove one notebook's schedule")}
$ deepnote schedule project.deepnote --notebook "Daily report" --remove

${c.bold('Authentication:')}
Set ${c.dim(DEEPNOTE_TOKEN_ENV)} in your environment or in a .env file next to the notebook.
You can also pass ${c.dim('--token')}. Scheduling availability depends on your Deepnote plan.
You can also pass ${c.dim('--token')}. Scheduling availability depends on your Deepnote plan;
--remove works on any plan.

${c.bold('Exit Codes:')}
${c.dim('0')} Success
${c.dim('0')} Success, including --remove with nothing to remove
${c.dim('1')} API or network failure
${c.dim('2')} Invalid usage, authentication, or plan permissions
`
Expand Down
134 changes: 134 additions & 0 deletions packages/cli/src/commands/schedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import { resetOutputConfig } from '../output'

const localRunnerMock = vi.hoisted(() => ({
scheduleInCloud: vi.fn(),
unscheduleInCloud: vi.fn(),
Comment thread
mfranczel marked this conversation as resolved.
Outdated
}))

vi.mock('@deepnote/local-runner', () => ({
scheduleInCloud: localRunnerMock.scheduleInCloud,
unscheduleInCloud: localRunnerMock.unscheduleInCloud,
}))

vi.mock('../utils/browser', () => ({
Expand Down Expand Up @@ -53,6 +55,15 @@ function options(overrides: Partial<ScheduleOptions> = {}): ScheduleOptions {
}
}

function removeOptions(overrides: Partial<ScheduleOptions> = {}): ScheduleOptions {
return {
create: true,
token: 'test-token',
remove: true,
...overrides,
}
}

describe('schedule command', () => {
let logSpy: ReturnType<typeof vi.spyOn>
let errorSpy: ReturnType<typeof vi.spyOn>
Expand All @@ -72,6 +83,7 @@ describe('schedule command', () => {
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
localRunnerMock.scheduleInCloud.mockResolvedValue(RESULT)
localRunnerMock.unscheduleInCloud.mockResolvedValue({ notebookId: NOTEBOOK_ID, removed: true })
vi.mocked(openInBrowser).mockResolvedValue(undefined)
})

Expand Down Expand Up @@ -285,4 +297,126 @@ version: '1.0.0'
error: 'Cloud unavailable',
})
})

describe('--remove', () => {
it('removes the notebook schedule without scheduling or creating anything', async () => {
await createScheduleAction(new Command())(HELLO_WORLD_FILE, removeOptions())

expect(localRunnerMock.unscheduleInCloud).toHaveBeenCalledOnce()
expect(localRunnerMock.unscheduleInCloud).toHaveBeenCalledWith(HELLO_WORLD_FILE, {
token: 'test-token',
baseUrl: 'https://api.deepnote.com',
notebookId: undefined,
})
expect(localRunnerMock.scheduleInCloud).not.toHaveBeenCalled()
expect(logSpy.mock.calls.flat().join('\n')).toContain('Schedule removed from Deepnote Cloud')
expect(process.exitCode).toBeUndefined()
})

it('reports a notebook without a schedule as nothing to remove', async () => {
localRunnerMock.unscheduleInCloud.mockResolvedValueOnce({ notebookId: NOTEBOOK_ID, removed: false })

await createScheduleAction(new Command())(HELLO_WORLD_FILE, removeOptions())

const printed = logSpy.mock.calls.flat().join('\n')
expect(printed).toContain('Nothing to remove: this notebook has no schedule.')
expect(printed).not.toContain('Schedule removed')
expect(process.exitCode).toBeUndefined()
})

it('reports a notebook missing from Deepnote Cloud without claiming a removal', async () => {
localRunnerMock.unscheduleInCloud.mockResolvedValueOnce({ notebookId: null, removed: false })

await createScheduleAction(new Command())(HELLO_WORLD_FILE, removeOptions())

const printed = logSpy.mock.calls.flat().join('\n')
expect(printed).toContain('Nothing removed: this notebook was not found in Deepnote Cloud.')
expect(printed).not.toContain('Schedule removed')
expect(process.exitCode).toBeUndefined()
})

it.each([
['--hourly', { hourly: true }],
['--daily', { daily: true }],
['--weekly', { weekly: 'Monday' }],
['--monthly', { monthly: '1' }],
['--cron', { cron: '0 9 * * *' }],
['--at', { at: '09:00' }],
['--timezone', { timezone: 'UTC' }],
['--open', { open: true }],
] satisfies Array<[string, Partial<ScheduleOptions>]>)(
'rejects --remove combined with %s',
async (flag, overrides) => {
await createScheduleAction(new Command())(HELLO_WORLD_FILE, removeOptions(overrides))

expect(process.exitCode).toBe(2)
expect(errorSpy.mock.calls.flat().join('\n')).toContain(`--remove cannot be combined with ${flag}`)
expect(localRunnerMock.unscheduleInCloud).not.toHaveBeenCalled()
expect(localRunnerMock.scheduleInCloud).not.toHaveBeenCalled()
}
)

it('removes the schedule of the notebook selected with --notebook', async () => {
await createScheduleAction(new Command())(MULTI_NOTEBOOK_FILE, removeOptions({ notebook: 'Data' }))

expect(process.exitCode).toBeUndefined()
expect(localRunnerMock.unscheduleInCloud).toHaveBeenCalledWith(
MULTI_NOTEBOOK_FILE,
expect.objectContaining({ notebookId: 'efceb59f45724c68b1e39b366bd30ff2' })
)
})

it('requires --notebook to remove a schedule from a multi-notebook file', async () => {
await createScheduleAction(new Command())(MULTI_NOTEBOOK_FILE, removeOptions())

expect(process.exitCode).toBe(2)
expect(localRunnerMock.unscheduleInCloud).not.toHaveBeenCalled()
expect(errorSpy.mock.calls.flat().join('\n')).toContain('--notebook')
})

it.each([
[{ notebookId: NOTEBOOK_ID, removed: true }, NOTEBOOK_ID, true],
[{ notebookId: NOTEBOOK_ID, removed: false }, NOTEBOOK_ID, false],
[{ notebookId: null, removed: false }, null, false],
])(
'outputs a single machine-readable JSON document for a removal (case %#)',
async (result, notebookId, removed) => {
localRunnerMock.unscheduleInCloud.mockResolvedValueOnce(result)

await createScheduleAction(new Command())(HELLO_WORLD_FILE, removeOptions({ output: 'json' }))

expect(JSON.parse(logSpy.mock.calls.flat().join('\n'))).toEqual({
success: true,
path: HELLO_WORLD_FILE,
notebookId,
removed,
})
expect(errorSpy).not.toHaveBeenCalled()
}
)

it('requires a token before calling Deepnote', async () => {
vi.stubEnv('DEEPNOTE_TOKEN', '')
try {
await createScheduleAction(new Command())(HELLO_WORLD_FILE, removeOptions({ token: undefined }))
} finally {
Comment thread
mfranczel marked this conversation as resolved.
Outdated
vi.unstubAllEnvs()
}

expect(process.exitCode).toBe(2)
expect(localRunnerMock.unscheduleInCloud).not.toHaveBeenCalled()
expect(errorSpy.mock.calls.flat().join('\n')).toContain('Missing authentication token')
})

it('reports an authentication failure as invalid usage', async () => {
localRunnerMock.unscheduleInCloud.mockRejectedValueOnce(
new ApiError(401, 'Authentication failed. Please check your API token.')
)

await createScheduleAction(new Command())(HELLO_WORLD_FILE, removeOptions())

expect(process.exitCode).toBe(2)
expect(errorSpy.mock.calls.flat().join('\n')).toContain('Authentication failed')
})
})
})
Loading
Loading