@deepnote/convert 2.2.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
- import { basename, dirname, extname, join } from "node:path";
3
- import { createMarkdown, createPythonCode, deepnoteBlockSchema, deserializeDeepnoteFile, environmentSchema, executionSchema } from "@deepnote/blocks";
4
- import { randomUUID } from "node:crypto";
2
+ import { basename, dirname, extname, join, resolve } from "node:path";
3
+ import { createMarkdown, createPythonCode, deepnoteBlockSchema, deepnoteSnapshotSchema, deserializeDeepnoteFile, environmentSchema, executionSchema, isExecutableBlockType } from "@deepnote/blocks";
5
4
  import { parse, stringify } from "yaml";
5
+ import { createHash, randomUUID } from "node:crypto";
6
6
 
7
7
  //#region src/utils.ts
8
8
  /**
@@ -643,11 +643,49 @@ function convertBlockToCell(block) {
643
643
  //#region src/format-detection.ts
644
644
  /** Check if file content is Marimo format */
645
645
  function isMarimoContent(content) {
646
- return /^import marimo\b/m.test(content) && /@app\.cell\b/.test(content) && !/^\s*['"]{3}[\s\S]*?import marimo/m.test(content);
646
+ const lines = content.split("\n");
647
+ let inDocstring = false;
648
+ let docstringQuote = "";
649
+ const firstLine = lines.find((line) => {
650
+ const t = line.trim();
651
+ if (t.length === 0) return false;
652
+ if (t.startsWith("#!") || /^#\s*(-\*-\s*)?(coding|encoding)[:=]/i.test(t)) return false;
653
+ for (const q of ["\"\"\"", "'''"]) {
654
+ if (!inDocstring && t.startsWith(q)) {
655
+ if (t.length > 3 && t.endsWith(q)) return false;
656
+ inDocstring = true;
657
+ docstringQuote = q;
658
+ return false;
659
+ }
660
+ if (inDocstring && docstringQuote === q && t.includes(q)) {
661
+ inDocstring = false;
662
+ return false;
663
+ }
664
+ }
665
+ if (inDocstring) return false;
666
+ return true;
667
+ }) ?? "";
668
+ return (/^import\s+marimo\b/.test(firstLine) || /^from\s+marimo\s+import\b/.test(firstLine)) && /@app\.cell\b/.test(content);
647
669
  }
648
670
  /** Check if file content is percent format */
649
671
  function isPercentContent(content) {
650
- return /^# %%/m.test(content) && !/^\s*['"]{3}[\s\S]*?# %%/m.test(content);
672
+ const lines = content.split("\n");
673
+ let inTripleQuote = false;
674
+ let quoteChar = "";
675
+ for (const line of lines) {
676
+ for (const q of ["\"\"\"", "'''"]) {
677
+ let idx = line.indexOf(q, 0);
678
+ while (idx !== -1) {
679
+ if (!inTripleQuote) {
680
+ inTripleQuote = true;
681
+ quoteChar = q;
682
+ } else if (quoteChar === q) inTripleQuote = false;
683
+ idx = line.indexOf(q, idx + 3);
684
+ }
685
+ }
686
+ if (!inTripleQuote && /^# %%/.test(line)) return true;
687
+ }
688
+ return false;
651
689
  }
652
690
  /**
653
691
  * Detects the notebook format from filename and optionally content.
@@ -749,9 +787,14 @@ function convertJupyterNotebooksToDeepnote(notebooks, options) {
749
787
  return deepnoteFile;
750
788
  }
751
789
  /**
752
- * Converts multiple Jupyter Notebook (.ipynb) files into a single Deepnote project file.
790
+ * Reads and converts multiple Jupyter Notebook (.ipynb) files into a DeepnoteFile.
791
+ * This function reads the files and returns the converted DeepnoteFile without writing to disk.
792
+ *
793
+ * @param inputFilePaths - Array of paths to .ipynb files
794
+ * @param options - Conversion options including project name
795
+ * @returns A DeepnoteFile object
753
796
  */
754
- async function convertIpynbFilesToDeepnoteFile(inputFilePaths, options) {
797
+ async function readAndConvertIpynbFiles(inputFilePaths, options) {
755
798
  const notebooks = [];
756
799
  for (const filePath of inputFilePaths) {
757
800
  const notebook = await parseIpynbFile(filePath);
@@ -760,7 +803,13 @@ async function convertIpynbFilesToDeepnoteFile(inputFilePaths, options) {
760
803
  notebook
761
804
  });
762
805
  }
763
- const yamlContent = stringify(convertJupyterNotebooksToDeepnote(notebooks, { projectName: options.projectName }));
806
+ return convertJupyterNotebooksToDeepnote(notebooks, { projectName: options.projectName });
807
+ }
808
+ /**
809
+ * Converts multiple Jupyter Notebook (.ipynb) files into a single Deepnote project file.
810
+ */
811
+ async function convertIpynbFilesToDeepnoteFile(inputFilePaths, options) {
812
+ const yamlContent = stringify(await readAndConvertIpynbFiles(inputFilePaths, { projectName: options.projectName }));
764
813
  const parentDir = dirname(options.outputPath);
765
814
  await fs.mkdir(parentDir, { recursive: true });
766
815
  await fs.writeFile(options.outputPath, yamlContent, "utf-8");
@@ -790,7 +839,10 @@ function convertCellToBlock$3(cell, index, idGenerator) {
790
839
  const executionFinishedAt = cell.metadata?.deepnote_execution_finished_at;
791
840
  const blockGroup = cell.metadata?.deepnote_block_group ?? cell.block_group ?? idGenerator();
792
841
  const deepnoteSource = cell.metadata?.deepnote_source;
793
- if (deepnoteSource !== void 0) source = deepnoteSource;
842
+ const isPlainCodeBlock = cell.cell_type === "code" && (!deepnoteCellType || deepnoteCellType === "code");
843
+ if (deepnoteSource !== void 0) if (isPlainCodeBlock) {
844
+ if (deepnoteSource === source) source = deepnoteSource;
845
+ } else source = deepnoteSource;
794
846
  const blockType = deepnoteCellType ?? (cell.cell_type === "code" ? "code" : "markdown");
795
847
  const originalMetadata = { ...cell.metadata };
796
848
  delete originalMetadata.cell_id;
@@ -804,7 +856,7 @@ function convertCellToBlock$3(cell, index, idGenerator) {
804
856
  delete cell.block_group;
805
857
  const executionCount = cell.execution_count ?? void 0;
806
858
  const hasExecutionCount = executionCount !== void 0;
807
- const hasOutputs = cell.cell_type === "code" && cell.outputs !== void 0;
859
+ const hasOutputs$1 = cell.cell_type === "code" && cell.outputs !== void 0;
808
860
  return sortKeysAlphabetically(deepnoteBlockSchema.parse({
809
861
  blockGroup,
810
862
  content: source,
@@ -814,15 +866,444 @@ function convertCellToBlock$3(cell, index, idGenerator) {
814
866
  ...executionStartedAt ? { executionStartedAt } : {},
815
867
  id: cellId ?? idGenerator(),
816
868
  metadata: originalMetadata,
817
- ...hasOutputs ? { outputs: cell.outputs } : {},
869
+ ...hasOutputs$1 ? { outputs: cell.outputs } : {},
818
870
  sortingKey: sortingKey ?? createSortingKey(index),
819
871
  type: blockType
820
872
  }));
821
873
  }
822
874
 
875
+ //#endregion
876
+ //#region src/snapshot/hash.ts
877
+ /**
878
+ * Computes a SHA-256 hash of the given content.
879
+ *
880
+ * @param content - The content to hash
881
+ * @returns Hash string in format 'sha256:{hex}'
882
+ */
883
+ function computeContentHash(content) {
884
+ return `sha256:${createHash("sha256").update(content, "utf-8").digest("hex")}`;
885
+ }
886
+ /**
887
+ * Computes a snapshot hash from the file's key properties.
888
+ * The hash is based on: version, environment.hash, integrations, and all block contentHashes.
889
+ *
890
+ * @param file - The DeepnoteFile to compute hash for
891
+ * @returns Hash string in format 'sha256:{hex}'
892
+ */
893
+ function computeSnapshotHash(file) {
894
+ const parts = [];
895
+ parts.push(`version:${file.version}`);
896
+ if (file.environment?.hash) parts.push(`env:${file.environment.hash}`);
897
+ const sortedIntegrations = [...file.project.integrations ?? []].sort((a, b) => a.id.localeCompare(b.id));
898
+ for (const integration of sortedIntegrations) parts.push(`integration:${integration.id}:${integration.type}`);
899
+ for (const notebook of file.project.notebooks) for (const block of notebook.blocks) if (block.contentHash) parts.push(`block:${block.id}:${block.contentHash}`);
900
+ const combined = parts.join("\n");
901
+ return `sha256:${createHash("sha256").update(combined, "utf-8").digest("hex")}`;
902
+ }
903
+ /**
904
+ * Adds content hashes to all blocks in a DeepnoteFile that don't already have them.
905
+ * Returns a new DeepnoteFile with hashes added (does not mutate the input).
906
+ *
907
+ * @param file - The DeepnoteFile to add hashes to
908
+ * @returns A new DeepnoteFile with content hashes added
909
+ */
910
+ function addContentHashes(file) {
911
+ const result = structuredClone(file);
912
+ for (const notebook of result.project.notebooks) for (const block of notebook.blocks) if (!block.contentHash && block.content) block.contentHash = computeContentHash(block.content);
913
+ return result;
914
+ }
915
+
916
+ //#endregion
917
+ //#region src/snapshot/lookup.ts
918
+ /** Default directory name for snapshots */
919
+ const DEFAULT_SNAPSHOT_DIR = "snapshots";
920
+ /** Regex pattern for snapshot filenames */
921
+ const SNAPSHOT_FILENAME_PATTERN = /^(.+)_([0-9a-f-]{36})_(latest|[\dT:-]+)\.snapshot\.deepnote$/;
922
+ /**
923
+ * Parses a snapshot filename into its components.
924
+ *
925
+ * @param filename - The snapshot filename to parse
926
+ * @returns Parsed components or null if filename doesn't match pattern
927
+ */
928
+ function parseSnapshotFilename(filename) {
929
+ const match = SNAPSHOT_FILENAME_PATTERN.exec(filename);
930
+ if (!match) return null;
931
+ return {
932
+ slug: match[1],
933
+ projectId: match[2],
934
+ timestamp: match[3]
935
+ };
936
+ }
937
+ /**
938
+ * Finds all snapshot files for a given project.
939
+ *
940
+ * @param projectDir - Directory containing the .deepnote file
941
+ * @param projectId - The project UUID to search for
942
+ * @param options - Snapshot options
943
+ * @returns Array of SnapshotInfo objects, sorted by timestamp (newest first)
944
+ */
945
+ async function findSnapshotsForProject(projectDir, projectId, options = {}) {
946
+ const snapshotsPath = resolve(projectDir, options.snapshotDir ?? DEFAULT_SNAPSHOT_DIR);
947
+ try {
948
+ const entries = await fs.readdir(snapshotsPath, { withFileTypes: true });
949
+ const snapshots = [];
950
+ for (const entry of entries) {
951
+ if (!entry.isFile() || !entry.name.endsWith(".snapshot.deepnote")) continue;
952
+ const parsed = parseSnapshotFilename(entry.name);
953
+ if (parsed && parsed.projectId === projectId) snapshots.push({
954
+ path: join(snapshotsPath, entry.name),
955
+ slug: parsed.slug,
956
+ projectId: parsed.projectId,
957
+ timestamp: parsed.timestamp
958
+ });
959
+ }
960
+ snapshots.sort((a, b) => {
961
+ if (a.timestamp === "latest") return -1;
962
+ if (b.timestamp === "latest") return 1;
963
+ return b.timestamp.localeCompare(a.timestamp);
964
+ });
965
+ return snapshots;
966
+ } catch (err) {
967
+ if (err.code === "ENOENT") return [];
968
+ throw err;
969
+ }
970
+ }
971
+ /**
972
+ * Loads the latest snapshot for a project.
973
+ *
974
+ * @param sourceFilePath - Path to the source .deepnote file
975
+ * @param projectId - The project UUID
976
+ * @param options - Snapshot options
977
+ * @returns The parsed DeepnoteSnapshot or null if not found
978
+ */
979
+ async function loadLatestSnapshot(sourceFilePath, projectId, options = {}) {
980
+ const snapshots = await findSnapshotsForProject(dirname(sourceFilePath), projectId, options);
981
+ if (snapshots.length === 0) return null;
982
+ const snapshotInfo = snapshots[0];
983
+ return loadSnapshotFile(snapshotInfo.path);
984
+ }
985
+ /**
986
+ * Loads and parses a snapshot file.
987
+ *
988
+ * @param snapshotPath - Path to the snapshot file
989
+ * @returns The parsed DeepnoteSnapshot
990
+ */
991
+ async function loadSnapshotFile(snapshotPath) {
992
+ const parsed = parse(await fs.readFile(snapshotPath, "utf-8"));
993
+ return deepnoteSnapshotSchema.parse(parsed);
994
+ }
995
+ /**
996
+ * Gets the snapshot directory path for a source file.
997
+ *
998
+ * @param sourceFilePath - Path to the source .deepnote file
999
+ * @param options - Snapshot options
1000
+ * @returns The snapshot directory path
1001
+ */
1002
+ function getSnapshotDir(sourceFilePath, options = {}) {
1003
+ const snapshotDir = options.snapshotDir ?? DEFAULT_SNAPSHOT_DIR;
1004
+ return resolve(dirname(sourceFilePath), snapshotDir);
1005
+ }
1006
+ /**
1007
+ * Checks if a snapshot exists for a project.
1008
+ *
1009
+ * @param sourceFilePath - Path to the source .deepnote file
1010
+ * @param projectId - The project UUID
1011
+ * @param options - Snapshot options
1012
+ * @returns True if at least one snapshot exists
1013
+ */
1014
+ async function snapshotExists(sourceFilePath, projectId, options = {}) {
1015
+ return (await findSnapshotsForProject(dirname(sourceFilePath), projectId, options)).length > 0;
1016
+ }
1017
+ /**
1018
+ * Extracts project information from a source file path.
1019
+ *
1020
+ * @param sourceFilePath - Path to the source .deepnote file
1021
+ * @returns Object with directory and filename without extension
1022
+ */
1023
+ function parseSourceFilePath(sourceFilePath) {
1024
+ return {
1025
+ dir: dirname(sourceFilePath),
1026
+ name: basename(sourceFilePath).replace(/\.deepnote$/, "")
1027
+ };
1028
+ }
1029
+
1030
+ //#endregion
1031
+ //#region src/snapshot/marimo-outputs.ts
1032
+ /**
1033
+ * Finds the Marimo session cache file for a given .py file.
1034
+ * Looks in __marimo__/session/{filename}.json relative to the file's directory.
1035
+ *
1036
+ * @param marimoFilePath - Path to the .py Marimo file
1037
+ * @returns Path to the session cache file, or null if not found
1038
+ */
1039
+ async function findMarimoSessionCache(marimoFilePath) {
1040
+ const sessionPath = join(dirname(marimoFilePath), "__marimo__", "session", `${basename(marimoFilePath)}.json`);
1041
+ try {
1042
+ await fs.access(sessionPath);
1043
+ return sessionPath;
1044
+ } catch {
1045
+ return null;
1046
+ }
1047
+ }
1048
+ /**
1049
+ * Reads and parses a Marimo session cache file.
1050
+ *
1051
+ * @param sessionPath - Path to the session cache JSON file
1052
+ * @returns The parsed session cache, or null if reading fails
1053
+ */
1054
+ async function readMarimoSessionCache(sessionPath) {
1055
+ try {
1056
+ const content = await fs.readFile(sessionPath, "utf-8");
1057
+ return JSON.parse(content);
1058
+ } catch (_error) {
1059
+ return null;
1060
+ }
1061
+ }
1062
+ /**
1063
+ * Converts a Marimo session output to Jupyter/Deepnote output format.
1064
+ *
1065
+ * @param output - The Marimo session output
1066
+ * @returns A Jupyter-compatible output object
1067
+ */
1068
+ function convertMarimoOutputToJupyter(output) {
1069
+ if (output.type === "error") return {
1070
+ output_type: "error",
1071
+ ename: output.ename || "Error",
1072
+ evalue: output.evalue || "",
1073
+ traceback: output.traceback || []
1074
+ };
1075
+ if (output.data) return {
1076
+ output_type: "execute_result",
1077
+ data: output.data,
1078
+ metadata: {},
1079
+ execution_count: null
1080
+ };
1081
+ return {
1082
+ output_type: "execute_result",
1083
+ data: { "text/plain": "" },
1084
+ metadata: {},
1085
+ execution_count: null
1086
+ };
1087
+ }
1088
+ /**
1089
+ * Converts Marimo console outputs to Jupyter stream outputs.
1090
+ *
1091
+ * @param consoleOutputs - Array of Marimo console outputs
1092
+ * @returns Array of Jupyter stream outputs
1093
+ */
1094
+ function convertMarimoConsoleToJupyter(consoleOutputs) {
1095
+ const outputs = [];
1096
+ for (const consoleOutput of consoleOutputs) outputs.push({
1097
+ output_type: "stream",
1098
+ name: consoleOutput.channel,
1099
+ text: consoleOutput.data
1100
+ });
1101
+ return outputs;
1102
+ }
1103
+ /**
1104
+ * Converts a full Marimo session cell to Jupyter outputs.
1105
+ *
1106
+ * @param cell - The Marimo session cell
1107
+ * @returns Array of Jupyter-compatible outputs
1108
+ */
1109
+ function convertMarimoSessionCellToOutputs(cell) {
1110
+ const outputs = [];
1111
+ outputs.push(...convertMarimoConsoleToJupyter(cell.console ?? []));
1112
+ for (const output of cell.outputs) outputs.push(convertMarimoOutputToJupyter(output));
1113
+ return outputs;
1114
+ }
1115
+ /**
1116
+ * Gets outputs from the Marimo session cache file.
1117
+ * This is the preferred method as it doesn't require the marimo CLI.
1118
+ * Outputs are keyed by code_hash for reliable matching even when cells are reordered.
1119
+ *
1120
+ * @param marimoFilePath - Path to the .py Marimo file
1121
+ * @returns Map of code_hash to outputs, or null if session cache is not found
1122
+ */
1123
+ async function getMarimoOutputsFromCache(marimoFilePath) {
1124
+ const sessionPath = await findMarimoSessionCache(marimoFilePath);
1125
+ if (!sessionPath) return null;
1126
+ const cache = await readMarimoSessionCache(sessionPath);
1127
+ if (!cache) return null;
1128
+ const outputMap = /* @__PURE__ */ new Map();
1129
+ for (const cell of cache.cells) {
1130
+ const outputs = convertMarimoSessionCellToOutputs(cell);
1131
+ if (outputs.length > 0) {
1132
+ const nonEmptyOutputs = outputs.filter((o) => {
1133
+ if (o.output_type === "execute_result" && o.data) {
1134
+ const data = o.data;
1135
+ return Object.values(data).some((v) => {
1136
+ if (typeof v === "string") return v.trim() !== "";
1137
+ return v != null;
1138
+ });
1139
+ }
1140
+ return true;
1141
+ });
1142
+ if (nonEmptyOutputs.length > 0) outputMap.set(cell.code_hash, nonEmptyOutputs);
1143
+ }
1144
+ }
1145
+ return outputMap;
1146
+ }
1147
+
1148
+ //#endregion
1149
+ //#region src/snapshot/merge.ts
1150
+ /**
1151
+ * Merges outputs from a snapshot into a source file.
1152
+ * Returns a new DeepnoteFile with outputs added from the snapshot.
1153
+ *
1154
+ * @param source - The source DeepnoteFile (without outputs)
1155
+ * @param snapshot - The snapshot containing outputs
1156
+ * @param options - Merge options
1157
+ * @returns A new DeepnoteFile with outputs merged in
1158
+ */
1159
+ function mergeSnapshotIntoSource(source, snapshot, options = {}) {
1160
+ const { skipMismatched = false } = options;
1161
+ const outputMap = /* @__PURE__ */ new Map();
1162
+ for (const notebook of snapshot.project.notebooks) for (const block of notebook.blocks) {
1163
+ const execBlock = block;
1164
+ if (execBlock.outputs && execBlock.outputs.length > 0) outputMap.set(block.id, {
1165
+ contentHash: block.contentHash,
1166
+ executionCount: execBlock.executionCount,
1167
+ executionStartedAt: execBlock.executionStartedAt,
1168
+ executionFinishedAt: execBlock.executionFinishedAt,
1169
+ outputs: execBlock.outputs
1170
+ });
1171
+ }
1172
+ return {
1173
+ ...source,
1174
+ environment: snapshot.environment ?? source.environment,
1175
+ execution: snapshot.execution ?? source.execution,
1176
+ project: {
1177
+ ...source.project,
1178
+ notebooks: source.project.notebooks.map((notebook) => ({
1179
+ ...notebook,
1180
+ blocks: notebook.blocks.map((block) => {
1181
+ const snapshotData = outputMap.get(block.id);
1182
+ if (!snapshotData) return block;
1183
+ if (skipMismatched && snapshotData.contentHash && block.contentHash) {
1184
+ if (snapshotData.contentHash !== block.contentHash) return block;
1185
+ }
1186
+ return {
1187
+ ...block,
1188
+ ...snapshotData.executionCount !== void 0 ? { executionCount: snapshotData.executionCount } : {},
1189
+ ...snapshotData.executionStartedAt ? { executionStartedAt: snapshotData.executionStartedAt } : {},
1190
+ ...snapshotData.executionFinishedAt ? { executionFinishedAt: snapshotData.executionFinishedAt } : {},
1191
+ ...snapshotData.outputs ? { outputs: snapshotData.outputs } : {}
1192
+ };
1193
+ })
1194
+ }))
1195
+ }
1196
+ };
1197
+ }
1198
+ /**
1199
+ * Counts blocks with outputs in a file.
1200
+ *
1201
+ * @param file - The DeepnoteFile to count outputs in
1202
+ * @returns Number of blocks that have outputs
1203
+ */
1204
+ function countBlocksWithOutputs(file) {
1205
+ let count = 0;
1206
+ for (const notebook of file.project.notebooks) for (const block of notebook.blocks) {
1207
+ const execBlock = block;
1208
+ if (execBlock.outputs && execBlock.outputs.length > 0) count++;
1209
+ }
1210
+ return count;
1211
+ }
1212
+
1213
+ //#endregion
1214
+ //#region src/snapshot/split.ts
1215
+ /**
1216
+ * Creates a slug from a project name.
1217
+ * Normalizes accented characters to ASCII equivalents (e.g., é → e),
1218
+ * converts to lowercase, replaces spaces and special chars with hyphens,
1219
+ * removes consecutive hyphens, and trims leading/trailing hyphens.
1220
+ *
1221
+ * @param name - The project name to slugify
1222
+ * @returns A URL-safe slug
1223
+ */
1224
+ function slugifyProjectName(name) {
1225
+ return name.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1226
+ }
1227
+ /**
1228
+ * Generates a snapshot filename from project info.
1229
+ *
1230
+ * @param slug - The project name slug
1231
+ * @param projectId - The project UUID
1232
+ * @param timestamp - Timestamp string or 'latest'
1233
+ * @returns Filename in format '{slug}_{projectId}_{timestamp}.snapshot.deepnote'
1234
+ */
1235
+ function generateSnapshotFilename(slug, projectId, timestamp = "latest") {
1236
+ return `${slug}_${projectId}_${timestamp}.snapshot.deepnote`;
1237
+ }
1238
+ /**
1239
+ * Removes output-related fields from a block, returning a clean source block.
1240
+ */
1241
+ function stripOutputsFromBlock(block) {
1242
+ if (!isExecutableBlockType(block.type)) return block;
1243
+ const { executionCount: _executionCount, executionStartedAt: _executionStartedAt, executionFinishedAt: _executionFinishedAt, outputs: _outputs,...rest } = block;
1244
+ return rest;
1245
+ }
1246
+ /**
1247
+ * Splits a DeepnoteFile into a source file (no outputs) and a snapshot file (outputs only).
1248
+ *
1249
+ * @param file - The complete DeepnoteFile with outputs
1250
+ * @returns Object containing source and snapshot files
1251
+ */
1252
+ function splitDeepnoteFile(file) {
1253
+ const fileWithHashes = addContentHashes(file);
1254
+ const snapshotHash = computeSnapshotHash(fileWithHashes);
1255
+ const { snapshotHash: _snapshotHash,...sourceMetadata } = fileWithHashes.metadata ?? {};
1256
+ return {
1257
+ source: {
1258
+ ...fileWithHashes,
1259
+ metadata: sourceMetadata,
1260
+ project: {
1261
+ ...fileWithHashes.project,
1262
+ notebooks: fileWithHashes.project.notebooks.map((notebook) => ({
1263
+ ...notebook,
1264
+ blocks: notebook.blocks.map(stripOutputsFromBlock)
1265
+ }))
1266
+ }
1267
+ },
1268
+ snapshot: {
1269
+ ...fileWithHashes,
1270
+ environment: fileWithHashes.environment ?? {},
1271
+ execution: fileWithHashes.execution ?? {},
1272
+ metadata: {
1273
+ ...fileWithHashes.metadata,
1274
+ snapshotHash
1275
+ }
1276
+ }
1277
+ };
1278
+ }
1279
+ /**
1280
+ * Checks if a DeepnoteFile has any outputs.
1281
+ *
1282
+ * @param file - The DeepnoteFile to check
1283
+ * @returns True if any block has outputs
1284
+ */
1285
+ function hasOutputs(file) {
1286
+ for (const notebook of file.project.notebooks) for (const block of notebook.blocks) {
1287
+ if (!isExecutableBlockType(block.type)) continue;
1288
+ const execBlock = block;
1289
+ if (execBlock.outputs && execBlock.outputs.length > 0) return true;
1290
+ }
1291
+ return false;
1292
+ }
1293
+
823
1294
  //#endregion
824
1295
  //#region src/marimo-to-deepnote.ts
825
1296
  /**
1297
+ * Computes a code hash for a cell's content.
1298
+ * This matches how Marimo computes code_hash for the session cache.
1299
+ *
1300
+ * @param content - The cell's code content
1301
+ * @returns An MD5 hash string (32 hex chars)
1302
+ */
1303
+ function computeCodeHash(content) {
1304
+ return createHash("md5").update(content, "utf-8").digest("hex");
1305
+ }
1306
+ /**
826
1307
  * Splits a string on commas that are at the top level (not inside parentheses, brackets, braces, or string literals).
827
1308
  * This handles cases like "func(a, b), other" and 'return "a,b", x' correctly.
828
1309
  * Supports single quotes, double quotes, and backticks, with proper escape handling.
@@ -893,13 +1374,26 @@ function parseMarimoFormat(content) {
893
1374
  const generatedWith = /__generated_with\s*=\s*["']([^"']+)["']/.exec(content)?.[1];
894
1375
  const width = /(?:marimo|mo)\.App\([^)]*width\s*=\s*["']([^"']+)["']/.exec(content)?.[1];
895
1376
  const title = /(?:marimo|mo)\.App\([^)]*title\s*=\s*["']([^"']+)["']/.exec(content)?.[1];
896
- const cellRegex = /@app\.cell(?:\(([^)]*)\))?\s*\n\s*def\s+(\w+)\s*\(([^)]*)\)\s*(?:->.*?)?\s*:\s*\n([\s\S]*?)(?=@app\.cell|if\s+__name__|$)/g;
1377
+ const cellRegex = /@app\.(cell|function)(?:\(([^)]*)\))?\s*\n\s*def\s+(\w+)\s*\(([^)]*)\)\s*(?:->.*?)?\s*:\s*\n([\s\S]*?)(?=@app\.cell|@app\.function|if\s+__name__|$)/g;
897
1378
  let match = cellRegex.exec(content);
898
1379
  while (match !== null) {
899
- const decoratorArgs = match[1] || "";
900
- const functionName = match[2];
901
- const params = match[3].trim();
902
- let body = match[4];
1380
+ const decoratorType = match[1];
1381
+ const decoratorArgs = match[2] || "";
1382
+ const functionName = match[3];
1383
+ const params = match[4].trim();
1384
+ let body = match[5];
1385
+ if (decoratorType === "function") {
1386
+ const funcDef = `def ${functionName}(${params})${/@app\.function(?:\([^)]*\))?\s*\n\s*def\s+\w+\s*\([^)]*\)\s*(->.*?)?\s*:/.exec(content.slice(match.index))?.[1] || ""}:\n${body}`;
1387
+ cells.push({
1388
+ cellType: "code",
1389
+ content: funcDef.trim(),
1390
+ functionName,
1391
+ hidden: /hide_code\s*=\s*True/.test(decoratorArgs),
1392
+ disabled: /disabled\s*=\s*True/.test(decoratorArgs)
1393
+ });
1394
+ match = cellRegex.exec(content);
1395
+ continue;
1396
+ }
903
1397
  const dependencies = params ? params.split(",").map((p) => p.trim()).filter((p) => p.length > 0) : void 0;
904
1398
  const hidden = /hide_code\s*=\s*True/.test(decoratorArgs);
905
1399
  const disabled = /disabled\s*=\s*True/.test(decoratorArgs);
@@ -987,41 +1481,70 @@ function parseMarimoFormat(content) {
987
1481
  * This is the lowest-level conversion function.
988
1482
  *
989
1483
  * @param app - The Marimo app object to convert
990
- * @param options - Optional conversion options including custom ID generator
1484
+ * @param options - Optional conversion options including custom ID generator and outputs
991
1485
  * @returns Array of DeepnoteBlock objects
992
1486
  */
993
1487
  function convertMarimoAppToBlocks(app, options) {
994
1488
  const idGenerator = options?.idGenerator ?? randomUUID;
995
- return app.cells.map((cell, index) => convertCellToBlock$2(cell, index, idGenerator));
1489
+ const outputs = options?.outputs;
1490
+ return app.cells.map((cell, index) => {
1491
+ const codeHash = computeCodeHash(cell.content);
1492
+ const cellOutputs = outputs?.get(codeHash);
1493
+ return convertCellToBlock$2(cell, index, idGenerator, cellOutputs);
1494
+ });
996
1495
  }
997
1496
  /**
998
- * Converts Marimo app objects into a Deepnote project file.
999
- * This is a pure conversion function that doesn't perform any file I/O.
1000
- *
1001
- * @param apps - Array of Marimo apps with filenames
1002
- * @param options - Conversion options including project name and optional ID generator
1003
- * @returns A DeepnoteFile object
1497
+ * Creates a base DeepnoteFile structure with empty notebooks.
1498
+ * This is a helper to reduce duplication in conversion functions.
1004
1499
  */
1005
- function convertMarimoAppsToDeepnote(apps, options) {
1006
- const idGenerator = options.idGenerator ?? randomUUID;
1007
- const firstNotebookId = apps.length > 0 ? idGenerator() : void 0;
1008
- const deepnoteFile = {
1500
+ function createDeepnoteFileSkeleton(projectName, idGenerator, firstNotebookId) {
1501
+ return {
1009
1502
  metadata: { createdAt: (/* @__PURE__ */ new Date()).toISOString() },
1010
1503
  project: {
1011
1504
  id: idGenerator(),
1012
1505
  initNotebookId: firstNotebookId,
1013
1506
  integrations: [],
1014
- name: options.projectName,
1507
+ name: projectName,
1015
1508
  notebooks: [],
1016
1509
  settings: {}
1017
1510
  },
1018
1511
  version: "1.0.0"
1019
1512
  };
1513
+ }
1514
+ /**
1515
+ * Converts Marimo app objects into a Deepnote project file.
1516
+ * This is a pure conversion function that doesn't perform any file I/O.
1517
+ *
1518
+ * @param apps - Array of Marimo apps with filenames
1519
+ * @param options - Conversion options including project name and optional ID generator
1520
+ * @returns A DeepnoteFile object
1521
+ */
1522
+ function convertMarimoAppsToDeepnote(apps, options) {
1523
+ return convertMarimoAppsToDeepnoteFile(apps.map((app) => ({
1524
+ ...app,
1525
+ outputs: void 0
1526
+ })), options);
1527
+ }
1528
+ /**
1529
+ * Converts Marimo app objects with outputs into a Deepnote project file.
1530
+ * This variant includes outputs from the Marimo session cache.
1531
+ *
1532
+ * @param apps - Array of Marimo apps with filenames and optional outputs
1533
+ * @param options - Conversion options including project name and optional ID generator
1534
+ * @returns A DeepnoteFile object
1535
+ */
1536
+ function convertMarimoAppsToDeepnoteFile(apps, options) {
1537
+ const idGenerator = options.idGenerator ?? randomUUID;
1538
+ const firstNotebookId = apps.length > 0 ? idGenerator() : void 0;
1539
+ const deepnoteFile = createDeepnoteFileSkeleton(options.projectName, idGenerator, firstNotebookId);
1020
1540
  for (let i = 0; i < apps.length; i++) {
1021
- const { filename, app } = apps[i];
1541
+ const { filename, app, outputs } = apps[i];
1022
1542
  const filenameWithoutExt = basename(filename, extname(filename)) || "Untitled notebook";
1023
1543
  const notebookName = app.title || filenameWithoutExt;
1024
- const blocks = convertMarimoAppToBlocks(app, { idGenerator });
1544
+ const blocks = convertMarimoAppToBlocks(app, {
1545
+ idGenerator,
1546
+ outputs
1547
+ });
1025
1548
  const notebookId = i === 0 && firstNotebookId ? firstNotebookId : idGenerator();
1026
1549
  deepnoteFile.project.notebooks.push({
1027
1550
  blocks,
@@ -1034,15 +1557,45 @@ function convertMarimoAppsToDeepnote(apps, options) {
1034
1557
  return deepnoteFile;
1035
1558
  }
1036
1559
  /**
1560
+ * Reads and converts multiple Marimo (.py) files into a DeepnoteFile.
1561
+ * This function reads the files and returns the converted DeepnoteFile without writing to disk.
1562
+ *
1563
+ * @param inputFilePaths - Array of paths to Marimo .py files
1564
+ * @param options - Conversion options including project name
1565
+ * @returns A DeepnoteFile object
1566
+ */
1567
+ async function readAndConvertMarimoFiles(inputFilePaths, options) {
1568
+ const apps = [];
1569
+ for (const filePath of inputFilePaths) try {
1570
+ const app = parseMarimoFormat(await fs.readFile(filePath, "utf-8"));
1571
+ const outputs = await getMarimoOutputsFromCache(filePath);
1572
+ apps.push({
1573
+ filename: basename(filePath),
1574
+ app,
1575
+ outputs: outputs ?? void 0
1576
+ });
1577
+ } catch (err) {
1578
+ const errorMessage = err instanceof Error ? err.message : String(err);
1579
+ const errorStack = err instanceof Error ? err.stack : void 0;
1580
+ throw new Error(`Failed to read or parse file ${basename(filePath)}: ${errorMessage}`, { cause: errorStack ? {
1581
+ originalError: err,
1582
+ stack: errorStack
1583
+ } : err });
1584
+ }
1585
+ return convertMarimoAppsToDeepnoteFile(apps, { projectName: options.projectName });
1586
+ }
1587
+ /**
1037
1588
  * Converts multiple Marimo (.py) files into a single Deepnote project file.
1038
1589
  */
1039
1590
  async function convertMarimoFilesToDeepnoteFile(inputFilePaths, options) {
1040
1591
  const apps = [];
1041
1592
  for (const filePath of inputFilePaths) try {
1042
1593
  const app = parseMarimoFormat(await fs.readFile(filePath, "utf-8"));
1594
+ const outputs = await getMarimoOutputsFromCache(filePath);
1043
1595
  apps.push({
1044
1596
  filename: basename(filePath),
1045
- app
1597
+ app,
1598
+ outputs: outputs ?? void 0
1046
1599
  });
1047
1600
  } catch (err) {
1048
1601
  const errorMessage = err instanceof Error ? err.message : String(err);
@@ -1052,12 +1605,12 @@ async function convertMarimoFilesToDeepnoteFile(inputFilePaths, options) {
1052
1605
  stack: errorStack
1053
1606
  } : err });
1054
1607
  }
1055
- const yamlContent = stringify(convertMarimoAppsToDeepnote(apps, { projectName: options.projectName }));
1608
+ const yamlContent = stringify(convertMarimoAppsToDeepnoteFile(apps, { projectName: options.projectName }));
1056
1609
  const parentDir = dirname(options.outputPath);
1057
1610
  await fs.mkdir(parentDir, { recursive: true });
1058
1611
  await fs.writeFile(options.outputPath, yamlContent, "utf-8");
1059
1612
  }
1060
- function convertCellToBlock$2(cell, index, idGenerator) {
1613
+ function convertCellToBlock$2(cell, index, idGenerator, outputs) {
1061
1614
  let blockType;
1062
1615
  if (cell.cellType === "markdown") blockType = "markdown";
1063
1616
  else if (cell.cellType === "sql") blockType = "sql";
@@ -1075,7 +1628,8 @@ function convertCellToBlock$2(cell, index, idGenerator) {
1075
1628
  id: idGenerator(),
1076
1629
  metadata: Object.keys(metadata).length > 0 ? metadata : {},
1077
1630
  sortingKey: createSortingKey(index),
1078
- type: blockType
1631
+ type: blockType,
1632
+ ...outputs && outputs.length > 0 && (blockType === "code" || blockType === "sql") ? { outputs } : {}
1079
1633
  };
1080
1634
  }
1081
1635
 
@@ -1202,9 +1756,14 @@ function convertPercentNotebooksToDeepnote(notebooks, options) {
1202
1756
  return deepnoteFile;
1203
1757
  }
1204
1758
  /**
1205
- * Converts multiple percent format (.py) files into a single Deepnote project file.
1759
+ * Reads and converts multiple percent format (.py) files into a DeepnoteFile.
1760
+ * This function reads the files and returns the converted DeepnoteFile without writing to disk.
1761
+ *
1762
+ * @param inputFilePaths - Array of paths to percent format .py files
1763
+ * @param options - Conversion options including project name
1764
+ * @returns A DeepnoteFile object
1206
1765
  */
1207
- async function convertPercentFilesToDeepnoteFile(inputFilePaths, options) {
1766
+ async function readAndConvertPercentFiles(inputFilePaths, options) {
1208
1767
  const notebooks = [];
1209
1768
  for (const filePath of inputFilePaths) {
1210
1769
  const notebook = parsePercentFormat(await fs.readFile(filePath, "utf-8"));
@@ -1213,7 +1772,13 @@ async function convertPercentFilesToDeepnoteFile(inputFilePaths, options) {
1213
1772
  notebook
1214
1773
  });
1215
1774
  }
1216
- const yamlContent = stringify(convertPercentNotebooksToDeepnote(notebooks, { projectName: options.projectName }));
1775
+ return convertPercentNotebooksToDeepnote(notebooks, { projectName: options.projectName });
1776
+ }
1777
+ /**
1778
+ * Converts multiple percent format (.py) files into a single Deepnote project file.
1779
+ */
1780
+ async function convertPercentFilesToDeepnoteFile(inputFilePaths, options) {
1781
+ const yamlContent = stringify(await readAndConvertPercentFiles(inputFilePaths, { projectName: options.projectName }));
1217
1782
  const parentDir = dirname(options.outputPath);
1218
1783
  await fs.mkdir(parentDir, { recursive: true });
1219
1784
  await fs.writeFile(options.outputPath, yamlContent, "utf-8");
@@ -1464,9 +2029,14 @@ function convertQuartoDocumentsToDeepnote(documents, options) {
1464
2029
  return deepnoteFile;
1465
2030
  }
1466
2031
  /**
1467
- * Converts multiple Quarto (.qmd) files into a single Deepnote project file.
2032
+ * Reads and converts multiple Quarto (.qmd) files into a DeepnoteFile.
2033
+ * This function reads the files and returns the converted DeepnoteFile without writing to disk.
2034
+ *
2035
+ * @param inputFilePaths - Array of paths to .qmd files
2036
+ * @param options - Conversion options including project name
2037
+ * @returns A DeepnoteFile object
1468
2038
  */
1469
- async function convertQuartoFilesToDeepnoteFile(inputFilePaths, options) {
2039
+ async function readAndConvertQuartoFiles(inputFilePaths, options) {
1470
2040
  const documents = [];
1471
2041
  for (const filePath of inputFilePaths) {
1472
2042
  const document = parseQuartoFormat(await fs.readFile(filePath, "utf-8"));
@@ -1475,7 +2045,13 @@ async function convertQuartoFilesToDeepnoteFile(inputFilePaths, options) {
1475
2045
  document
1476
2046
  });
1477
2047
  }
1478
- const yamlContent = stringify(convertQuartoDocumentsToDeepnote(documents, { projectName: options.projectName }));
2048
+ return convertQuartoDocumentsToDeepnote(documents, { projectName: options.projectName });
2049
+ }
2050
+ /**
2051
+ * Converts multiple Quarto (.qmd) files into a single Deepnote project file.
2052
+ */
2053
+ async function convertQuartoFilesToDeepnoteFile(inputFilePaths, options) {
2054
+ const yamlContent = stringify(await readAndConvertQuartoFiles(inputFilePaths, { projectName: options.projectName }));
1479
2055
  const parentDir = dirname(options.outputPath);
1480
2056
  await fs.mkdir(parentDir, { recursive: true });
1481
2057
  await fs.writeFile(options.outputPath, yamlContent, "utf-8");
@@ -1502,4 +2078,41 @@ function convertCellToBlock(cell, index, idGenerator) {
1502
2078
  }
1503
2079
 
1504
2080
  //#endregion
1505
- export { serializeMarimoFormat as A, convertBlocksToPercentNotebook as C, convertBlocksToMarimoApp as D, serializePercentFormat as E, convertBlocksToJupyterNotebook as M, convertDeepnoteFileToJupyterFiles as N, convertDeepnoteFileToMarimoFiles as O, convertDeepnoteToJupyterNotebooks as P, serializeQuartoFormat as S, convertDeepnoteToPercentNotebooks as T, isMarimoContent as _, convertPercentFilesToDeepnoteFile as a, convertDeepnoteFileToQuartoFiles as b, parsePercentFormat as c, convertMarimoFilesToDeepnoteFile as d, parseMarimoFormat as f, detectFormat as g, convertJupyterNotebooksToDeepnote as h, parseQuartoFormat as i, convertBlockToJupyterCell as j, convertDeepnoteToMarimoApps as k, convertMarimoAppToBlocks as l, convertJupyterNotebookToBlocks as m, convertQuartoDocumentsToDeepnote as n, convertPercentNotebookToBlocks as o, convertIpynbFilesToDeepnoteFile as p, convertQuartoFilesToDeepnoteFile as r, convertPercentNotebooksToDeepnote as s, convertQuartoDocumentToBlocks as t, convertMarimoAppsToDeepnote as u, isPercentContent as v, convertDeepnoteFileToPercentFiles as w, convertDeepnoteToQuartoDocuments as x, convertBlocksToQuartoDocument as y };
2081
+ //#region src/write-deepnote-file.ts
2082
+ /**
2083
+ * Writes a DeepnoteFile to disk, optionally splitting outputs into a snapshot file.
2084
+ *
2085
+ * When singleFile is false (default) and the file contains outputs:
2086
+ * - Splits the file in memory into source (no outputs) and snapshot (with outputs)
2087
+ * - Writes both files in parallel
2088
+ *
2089
+ * When singleFile is true or there are no outputs:
2090
+ * - Writes the complete file as-is
2091
+ *
2092
+ * @param options - Write options including the file, output path, and project name
2093
+ * @returns Object containing paths to the written files
2094
+ */
2095
+ async function writeDeepnoteFile(options) {
2096
+ const { file, outputPath, projectName, singleFile = false } = options;
2097
+ const parentDir = dirname(outputPath);
2098
+ await fs.mkdir(parentDir, { recursive: true });
2099
+ if (singleFile || !hasOutputs(file)) {
2100
+ const yamlContent = stringify(file);
2101
+ await fs.writeFile(outputPath, yamlContent, "utf-8");
2102
+ return { sourcePath: outputPath };
2103
+ }
2104
+ const { source, snapshot } = splitDeepnoteFile(file);
2105
+ const snapshotDir = getSnapshotDir(outputPath);
2106
+ const snapshotPath = resolve(snapshotDir, generateSnapshotFilename(slugifyProjectName(projectName) || "project", file.project.id));
2107
+ const sourceYaml = stringify(source);
2108
+ const snapshotYaml = stringify(snapshot);
2109
+ await fs.mkdir(snapshotDir, { recursive: true });
2110
+ await Promise.all([fs.writeFile(outputPath, sourceYaml, "utf-8"), fs.writeFile(snapshotPath, snapshotYaml, "utf-8")]);
2111
+ return {
2112
+ sourcePath: outputPath,
2113
+ snapshotPath
2114
+ };
2115
+ }
2116
+
2117
+ //#endregion
2118
+ export { convertBlocksToJupyterNotebook as $, addContentHashes as A, convertBlocksToQuartoDocument as B, findSnapshotsForProject as C, parseSnapshotFilename as D, loadSnapshotFile as E, convertJupyterNotebooksToDeepnote as F, convertDeepnoteFileToPercentFiles as G, convertDeepnoteToQuartoDocuments as H, readAndConvertIpynbFiles as I, convertBlocksToMarimoApp as J, convertDeepnoteToPercentNotebooks as K, detectFormat as L, computeSnapshotHash as M, convertIpynbFilesToDeepnoteFile as N, parseSourceFilePath as O, convertJupyterNotebookToBlocks as P, convertBlockToJupyterCell as Q, isMarimoContent as R, mergeSnapshotIntoSource as S, loadLatestSnapshot as T, serializeQuartoFormat as U, convertDeepnoteFileToQuartoFiles as V, convertBlocksToPercentNotebook as W, convertDeepnoteToMarimoApps as X, convertDeepnoteFileToMarimoFiles as Y, serializeMarimoFormat as Z, generateSnapshotFilename as _, parseQuartoFormat as a, splitDeepnoteFile as b, convertPercentNotebookToBlocks as c, readAndConvertPercentFiles as d, convertDeepnoteFileToJupyterFiles as et, convertMarimoAppToBlocks as f, readAndConvertMarimoFiles as g, parseMarimoFormat as h, convertQuartoFilesToDeepnoteFile as i, computeContentHash as j, snapshotExists as k, convertPercentNotebooksToDeepnote as l, convertMarimoFilesToDeepnoteFile as m, convertQuartoDocumentToBlocks as n, readAndConvertQuartoFiles as o, convertMarimoAppsToDeepnote as p, serializePercentFormat as q, convertQuartoDocumentsToDeepnote as r, convertPercentFilesToDeepnoteFile as s, writeDeepnoteFile as t, convertDeepnoteToJupyterNotebooks as tt, parsePercentFormat as u, hasOutputs as v, getSnapshotDir as w, countBlocksWithOutputs as x, slugifyProjectName as y, isPercentContent as z };