From 2bddf181afae41a954e631a88598c665f0e334f9 Mon Sep 17 00:00:00 2001 From: Torrey Betts Date: Sat, 23 May 2026 20:37:12 -0400 Subject: [PATCH 1/3] Add preserve-format option and Jupyter export Introduce a preserve-original-format feature and Jupyter (.ipynb) export support. Adds PreserveFormat option across the CLI (repl/serve), Blazor host, and REPL session so implicit .save behavior can preserve an opened .ipynb instead of converting to .verso. ServerNotebookService now chooses a serializer based on the file path and PreserveFormat; NotebookHandler accepts an optional format param and routes post-processors and serializers accordingly (falling back to Verso). JupyterSerializer is extended to support serialization (nbformat v4) with write helpers and DTOs, and many serializer tests were added/updated to cover round-trips and outputs. The VS Code extension gains a setting to preserve original formats and the editor provider sends the requested format when saving. Unit tests were added/adjusted to validate the new behaviors. --- .../Services/NotebookServiceOptions.cs | 7 + .../Services/ServerNotebookService.cs | 13 +- src/Verso.Cli/Commands/ReplCommand.cs | 13 +- src/Verso.Cli/Commands/ServeCommand.cs | 10 +- src/Verso.Cli/Hosting/BlazorHostBuilder.cs | 2 + src/Verso.Cli/Repl/Meta/Commands/SaveMeta.cs | 19 +- src/Verso.Cli/Repl/ReplOptions.cs | 1 + src/Verso.Cli/Repl/ReplSession.cs | 7 + src/Verso.Host/Handlers/NotebookHandler.cs | 24 +- src/Verso.Host/HostSession.cs | 2 +- src/Verso/Serializers/JupyterSerializer.cs | 247 +++++++++++++++++- tests/Verso.Cli.Tests/Repl/SaveMetaTests.cs | 121 +++++++++ tests/Verso.Host.Tests/HandlerTests.cs | 37 ++- .../Handlers/PropertiesHandlerTests.cs | 2 +- .../Serializers/JupyterSerializerTests.cs | 227 +++++++++++++++- vscode/package.json | 5 + vscode/src/blazor/blazorEditorProvider.ts | 39 ++- 17 files changed, 742 insertions(+), 34 deletions(-) create mode 100644 tests/Verso.Cli.Tests/Repl/SaveMetaTests.cs diff --git a/src/Verso.Blazor/Services/NotebookServiceOptions.cs b/src/Verso.Blazor/Services/NotebookServiceOptions.cs index aa922e7..27e09e9 100644 --- a/src/Verso.Blazor/Services/NotebookServiceOptions.cs +++ b/src/Verso.Blazor/Services/NotebookServiceOptions.cs @@ -13,4 +13,11 @@ public sealed record NotebookServiceOptions /// built-in extensions are loaded. /// public string? ExtensionsDirectory { get; init; } + + /// + /// When true, saving a notebook that was loaded from a non-.verso file (e.g. + /// .ipynb) writes back to the original format instead of converting + /// to .verso. Defaults to false. + /// + public bool PreserveFormat { get; init; } } diff --git a/src/Verso.Blazor/Services/ServerNotebookService.cs b/src/Verso.Blazor/Services/ServerNotebookService.cs index f17add6..5c651ba 100644 --- a/src/Verso.Blazor/Services/ServerNotebookService.cs +++ b/src/Verso.Blazor/Services/ServerNotebookService.cs @@ -353,10 +353,21 @@ private async Task PrepareSerializedContentAsync() await sm.SaveSettingsAsync(_scaffold.Notebook); _scaffold.Notebook.Modified = DateTimeOffset.UtcNow; - var serializer = new VersoSerializer(); + INotebookSerializer serializer = ResolveSerializer(); return await serializer.SerializeAsync(_scaffold.Notebook); } + private INotebookSerializer ResolveSerializer() + { + if (_options.PreserveFormat + && !string.IsNullOrEmpty(_filePath) + && _filePath.EndsWith(".ipynb", StringComparison.OrdinalIgnoreCase)) + { + return new JupyterSerializer(); + } + return new VersoSerializer(); + } + // ── Cell operations ──────────────────────────────────────────────── public Task AddCellAsync(string type = "code", string? language = null) diff --git a/src/Verso.Cli/Commands/ReplCommand.cs b/src/Verso.Cli/Commands/ReplCommand.cs index b6ac2b2..2e772f9 100644 --- a/src/Verso.Cli/Commands/ReplCommand.cs +++ b/src/Verso.Cli/Commands/ReplCommand.cs @@ -70,6 +70,9 @@ public static Command Create() var listThemesOption = new Option("--list-themes", () => false, "Print registered themes and exit."); + var preserveFormatOption = new Option("--preserve-format", () => false, + "When the loaded notebook is .ipynb, .save (no arg) writes back to .ipynb instead of converting to .verso. Cell outputs are preserved."); + var command = new Command("repl", "Start an interactive Verso REPL in the terminal.") { notebookArg, @@ -82,7 +85,8 @@ public static Command Create() plainOption, historyOption, listKernelsOption, - listThemesOption + listThemesOption, + preserveFormatOption }; command.SetHandler(async (context) => @@ -98,6 +102,7 @@ public static Command Create() var historyArg = context.ParseResult.GetValueForOption(historyOption); var listKernels = context.ParseResult.GetValueForOption(listKernelsOption); var listThemes = context.ParseResult.GetValueForOption(listThemesOption); + var preserveFormat = context.ParseResult.GetValueForOption(preserveFormatOption); var ct = context.GetCancellationToken(); @@ -112,7 +117,8 @@ public static Command Create() NoColor = noColor, Plain = plain, HistoryPath = string.Equals(historyArg, "none", StringComparison.OrdinalIgnoreCase) ? null : historyArg, - HistoryDisabled = string.Equals(historyArg, "none", StringComparison.OrdinalIgnoreCase) + HistoryDisabled = string.Equals(historyArg, "none", StringComparison.OrdinalIgnoreCase), + PreserveFormat = preserveFormat }; context.ExitCode = await RunAsync(options, listKernels, listThemes, ct); @@ -230,7 +236,8 @@ internal static async Task RunAsync(ReplOptions options, bool listKernels, ActiveKernelId = notebook.DefaultKernelId, ActiveTheme = activeTheme, ActiveLayoutId = options.LayoutId, - Settings = ReplSettingsLoader.Load() + Settings = ReplSettingsLoader.Load(), + PreserveFormat = options.PreserveFormat }; // Execute pre-loaded cells when --execute is set. diff --git a/src/Verso.Cli/Commands/ServeCommand.cs b/src/Verso.Cli/Commands/ServeCommand.cs index 6f34e63..b3d4f98 100644 --- a/src/Verso.Cli/Commands/ServeCommand.cs +++ b/src/Verso.Cli/Commands/ServeCommand.cs @@ -32,6 +32,9 @@ public static Command Create() var verboseOption = new Option("--verbose", () => false, "Print startup details to stderr."); + var preserveFormatOption = new Option("--preserve-format", () => false, + "When a loaded .ipynb notebook is saved, write back to .ipynb instead of converting to .verso. Cell outputs are preserved."); + var command = new Command("serve", "Launch the Verso Blazor application as a local web server.") { notebookArg, @@ -39,7 +42,8 @@ public static Command Create() noBrowserOption, noHttpsOption, extensionsOption, - verboseOption + verboseOption, + preserveFormatOption }; command.SetHandler(async (context) => @@ -50,6 +54,7 @@ public static Command Create() var noHttps = context.ParseResult.GetValueForOption(noHttpsOption); var extensions = context.ParseResult.GetValueForOption(extensionsOption); var verbose = context.ParseResult.GetValueForOption(verboseOption); + var preserveFormat = context.ParseResult.GetValueForOption(preserveFormatOption); // Validate notebook path if provided string? notebookPath = null; @@ -71,7 +76,8 @@ public static Command Create() Port = port, NoHttps = noHttps, Verbose = verbose, - ExtensionsDirectory = extensions?.FullName + ExtensionsDirectory = extensions?.FullName, + PreserveFormat = preserveFormat }; var app = BlazorHostBuilder.Build(options); diff --git a/src/Verso.Cli/Hosting/BlazorHostBuilder.cs b/src/Verso.Cli/Hosting/BlazorHostBuilder.cs index 3241bed..710406e 100644 --- a/src/Verso.Cli/Hosting/BlazorHostBuilder.cs +++ b/src/Verso.Cli/Hosting/BlazorHostBuilder.cs @@ -18,6 +18,7 @@ public sealed record ServeOptions public bool NoHttps { get; init; } public bool Verbose { get; init; } public string? ExtensionsDirectory { get; init; } + public bool PreserveFormat { get; init; } } /// @@ -74,6 +75,7 @@ public static WebApplication Build(ServeOptions options) builder.Services.AddSingleton(new NotebookServiceOptions { ExtensionsDirectory = options.ExtensionsDirectory, + PreserveFormat = options.PreserveFormat, }); builder.Services.AddScoped(); diff --git a/src/Verso.Cli/Repl/Meta/Commands/SaveMeta.cs b/src/Verso.Cli/Repl/Meta/Commands/SaveMeta.cs index 6458d3e..1907dec 100644 --- a/src/Verso.Cli/Repl/Meta/Commands/SaveMeta.cs +++ b/src/Verso.Cli/Repl/Meta/Commands/SaveMeta.cs @@ -12,12 +12,15 @@ public sealed class SaveMeta : IMetaCommand public string DetailedHelp => ".save []\n" + " Serializes the session notebook. When is omitted, saves to the original\n" + - " loaded path (if any) or reports an error. Format is inferred from the extension."; + " loaded path (if any) or reports an error. Format is inferred from the extension.\n" + + " Without --preserve-format, a .save with no arg against an .ipynb-loaded notebook\n" + + " converts to a sibling .verso file; with --preserve-format the original format is kept."; public async Task ExecuteAsync(string argumentText, MetaContext context, CancellationToken ct) { var arg = argumentText.Trim(); - var targetPath = string.IsNullOrEmpty(arg) ? context.Session.NotebookPath : Path.GetFullPath(arg); + var explicitTarget = !string.IsNullOrEmpty(arg); + var targetPath = explicitTarget ? Path.GetFullPath(arg) : context.Session.NotebookPath; if (string.IsNullOrEmpty(targetPath)) { @@ -25,6 +28,18 @@ public async Task ExecuteAsync(string argumentText, MetaContext context, C return true; } + // Implicit-target saves on a non-.verso path: when the user has not asked to preserve + // the original format, route to a sibling .verso file (matching the VS Code default). + // Explicit `.save foo.ipynb` always honors the path the user typed. + if (!explicitTarget + && !context.Session.PreserveFormat + && !targetPath.EndsWith(".verso", StringComparison.OrdinalIgnoreCase)) + { + context.Console.MarkupLine( + $"[yellow]Converting to .verso; use --preserve-format to keep[/] {Markup.Escape(Path.GetExtension(targetPath))}."); + targetPath = Path.ChangeExtension(targetPath, ".verso"); + } + INotebookSerializer serializer; try { diff --git a/src/Verso.Cli/Repl/ReplOptions.cs b/src/Verso.Cli/Repl/ReplOptions.cs index f55c689..87941e6 100644 --- a/src/Verso.Cli/Repl/ReplOptions.cs +++ b/src/Verso.Cli/Repl/ReplOptions.cs @@ -15,4 +15,5 @@ public sealed class ReplOptions public bool Plain { get; init; } public string? HistoryPath { get; init; } public bool HistoryDisabled { get; init; } + public bool PreserveFormat { get; init; } } diff --git a/src/Verso.Cli/Repl/ReplSession.cs b/src/Verso.Cli/Repl/ReplSession.cs index 5473071..fe517f2 100644 --- a/src/Verso.Cli/Repl/ReplSession.cs +++ b/src/Verso.Cli/Repl/ReplSession.cs @@ -63,6 +63,13 @@ public sealed class ReplSession : IAsyncDisposable /// Runtime-mutable settings (row/line caps, elapsed threshold). Mutated by .set. public ReplSettings Settings { get; set; } = new(); + /// + /// When true, .save with no explicit path writes back to the loaded notebook's + /// original format (e.g. .ipynb) instead of converting to .verso. + /// Set by --preserve-format at startup. + /// + public bool PreserveFormat { get; set; } + public ReplSession(NotebookModel notebook, Scaffold scaffold, ExtensionHost extensionHost, string? notebookPath) { Notebook = notebook ?? throw new ArgumentNullException(nameof(notebook)); diff --git a/src/Verso.Host/Handlers/NotebookHandler.cs b/src/Verso.Host/Handlers/NotebookHandler.cs index 9c82a09..6cfd2f8 100644 --- a/src/Verso.Host/Handlers/NotebookHandler.cs +++ b/src/Verso.Host/Handlers/NotebookHandler.cs @@ -208,20 +208,36 @@ public static object HandleSetDefaultKernel(NotebookSession ns, JsonElement? @pa return new { success = true }; } - public static async Task HandleSaveAsync(NotebookSession ns) + public static async Task HandleSaveAsync(NotebookSession ns, JsonElement? @params) { + var format = "verso"; + if (@params?.TryGetProperty("format", out var fmtEl) == true + && fmtEl.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(fmtEl.GetString())) + { + format = fmtEl.GetString()!; + } + // Flush layout metadata (grid positions, etc.) into the notebook model if (ns.Scaffold.LayoutManager is { } lm) await lm.SaveMetadataAsync(ns.Scaffold.Notebook); - // Run post-processors before serialization + + // Run post-processors before serialization. The format key the post-processor + // sees matches the requested serializer so format-specific processors can opt in. + var postProcessorFormat = string.Equals(format, "verso", StringComparison.OrdinalIgnoreCase) + ? "verso-native" + : format; var notebook = ns.Scaffold.Notebook; var postProcessors = ns.ExtensionHost.GetPostProcessors() - .Where(pp => pp.CanProcess(null, "verso-native")) + .Where(pp => pp.CanProcess(null, postProcessorFormat)) .OrderBy(pp => pp.Priority); foreach (var pp in postProcessors) notebook = await pp.PreSerializeAsync(notebook, null); - var serializer = new VersoSerializer(); + var serializer = ns.ExtensionHost.GetSerializers() + .FirstOrDefault(s => string.Equals(s.FormatId, format, StringComparison.OrdinalIgnoreCase)) + ?? (INotebookSerializer)new VersoSerializer(); + var content = await serializer.SerializeAsync(notebook); return new NotebookSaveResult { Content = content }; } diff --git a/src/Verso.Host/HostSession.cs b/src/Verso.Host/HostSession.cs index 616aaee..90b044f 100644 --- a/src/Verso.Host/HostSession.cs +++ b/src/Verso.Host/HostSession.cs @@ -278,7 +278,7 @@ public async Task DispatchAsync(object id, string method, JsonElement? @ { MethodNames.NotebookSetFilePath => NotebookHandler.HandleSetFilePath(ns, @params), MethodNames.NotebookSetDefaultKernel => NotebookHandler.HandleSetDefaultKernel(ns, @params), - MethodNames.NotebookSave => await NotebookHandler.HandleSaveAsync(ns), + MethodNames.NotebookSave => await NotebookHandler.HandleSaveAsync(ns, @params), MethodNames.NotebookGetLanguages => NotebookHandler.HandleGetLanguages(ns), MethodNames.NotebookGetToolbarActions => NotebookHandler.HandleGetToolbarActions(ns), MethodNames.NotebookGetTheme => ThemeHandler.HandleGetTheme(ns), diff --git a/src/Verso/Serializers/JupyterSerializer.cs b/src/Verso/Serializers/JupyterSerializer.cs index bc094b9..d01a7f7 100644 --- a/src/Verso/Serializers/JupyterSerializer.cs +++ b/src/Verso/Serializers/JupyterSerializer.cs @@ -5,7 +5,7 @@ namespace Verso.Serializers; /// -/// Import-only serializer for Jupyter .ipynb notebooks (nbformat v4). +/// Serializer for Jupyter .ipynb notebooks (nbformat v4). /// [VersoExtension] public sealed class JupyterSerializer : INotebookSerializer @@ -15,13 +15,19 @@ public sealed class JupyterSerializer : INotebookSerializer PropertyNameCaseInsensitive = true }; + private static readonly JsonSerializerOptions WriteOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + // --- IExtension --- public string ExtensionId => "verso.serializer.jupyter"; public string Name => "Jupyter Serializer"; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Import-only serializer for Jupyter .ipynb notebooks."; + public string? Description => "Serializer for Jupyter .ipynb notebooks (nbformat v4)."; public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask; public Task OnUnloadedAsync() => Task.CompletedTask; @@ -39,7 +45,17 @@ public bool CanImport(string filePath) public Task SerializeAsync(NotebookModel notebook) { - throw new NotSupportedException("Jupyter .ipynb export is not supported. Use the Verso native format."); + ArgumentNullException.ThrowIfNull(notebook); + + var doc = new JupyterWriteNotebook + { + NbFormat = 4, + NbFormatMinor = 5, + Metadata = BuildMetadata(notebook.DefaultKernelId), + Cells = notebook.Cells.Select(BuildCell).ToList() + }; + + return Task.FromResult(JsonSerializer.Serialize(doc, WriteOptions)); } public Task DeserializeAsync(string content) @@ -96,6 +112,140 @@ public Task DeserializeAsync(string content) return Task.FromResult(notebook); } + // --- Write helpers --- + + // Code/markdown round-trip cleanly. Other cell types collapse to "raw" so structure + // survives — the Verso type label is preserved in cell.metadata.verso_type. + private static JupyterWriteCell BuildCell(CellModel cell) + { + var jupyterType = cell.Type?.ToLowerInvariant() switch + { + "code" => "code", + "markdown" => "markdown", + _ => "raw" + }; + + var writeCell = new JupyterWriteCell + { + CellType = jupyterType, + Source = SplitSourceForJupyter(cell.Source ?? "") + }; + + if (!string.Equals(jupyterType, cell.Type, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(cell.Type)) + { + writeCell.Metadata["verso_type"] = cell.Type!; + } + + foreach (var (key, value) in cell.Metadata) + { + if (key == "execution_count") continue; + writeCell.Metadata[key] = value; + } + + if (string.Equals(jupyterType, "code", StringComparison.OrdinalIgnoreCase)) + { + writeCell.ExecutionCount = TryGetExecutionCount(cell.Metadata); + writeCell.Outputs = cell.Outputs.Select(BuildOutput).ToList(); + } + + return writeCell; + } + + private static JupyterWriteOutput BuildOutput(CellOutput output) + { + if (output.IsError) + { + var lines = string.IsNullOrEmpty(output.ErrorStackTrace) + ? new List() + : output.ErrorStackTrace.Split('\n').ToList(); + return new JupyterWriteOutput + { + OutputType = "error", + EName = output.ErrorName ?? "Error", + EValue = output.Content ?? "", + Traceback = lines + }; + } + + if (string.Equals(output.MimeType, "text/plain", StringComparison.OrdinalIgnoreCase)) + { + return new JupyterWriteOutput + { + OutputType = "stream", + Name = "stdout", + Text = SplitSourceForJupyter(output.Content ?? "") + }; + } + + return new JupyterWriteOutput + { + OutputType = "display_data", + Data = new Dictionary> + { + [output.MimeType] = SplitSourceForJupyter(output.Content ?? "") + }, + Metadata = new Dictionary() + }; + } + + private static JupyterWriteMetadata BuildMetadata(string? defaultKernelId) + { + if (string.IsNullOrWhiteSpace(defaultKernelId)) + return new JupyterWriteMetadata(); + + var (name, displayName, language) = ReverseNormalizeKernel(defaultKernelId); + return new JupyterWriteMetadata + { + Kernelspec = new JupyterKernelSpec + { + Name = name, + DisplayName = displayName, + Language = language + }, + LanguageInfo = new JupyterLanguageInfo { Name = language } + }; + } + + private static (string Name, string DisplayName, string Language) ReverseNormalizeKernel(string kernelId) + { + return kernelId.ToLowerInvariant() switch + { + "csharp" => ("csharp", "C#", "csharp"), + "fsharp" => ("fsharp", "F#", "fsharp"), + "python" => ("python3", "Python 3", "python"), + _ => (kernelId, kernelId, kernelId) + }; + } + + private static List SplitSourceForJupyter(string source) + { + if (string.IsNullOrEmpty(source)) + return new List(); + + var lines = source.Split('\n'); + var result = new List(lines.Length); + for (int i = 0; i < lines.Length - 1; i++) + result.Add(lines[i] + "\n"); + result.Add(lines[lines.Length - 1]); + return result; + } + + private static int? TryGetExecutionCount(Dictionary metadata) + { + if (!metadata.TryGetValue("execution_count", out var value)) + return null; + + return value switch + { + int i => i, + long l => (int)l, + double d => (int)d, + JsonElement je when je.ValueKind == JsonValueKind.Number && je.TryGetInt32(out var v) => v, + _ => null + }; + } + // --- Mapping helpers --- private static string MapCellType(string? cellType) @@ -253,7 +403,96 @@ private static string ExtractMimeContent(JsonElement element) }; } - // --- Internal DTOs --- + // --- Internal write DTOs --- + + private sealed class JupyterWriteNotebook + { + [JsonPropertyName("cells")] + public List Cells { get; set; } = new(); + + [JsonPropertyName("metadata")] + public JupyterWriteMetadata Metadata { get; set; } = new(); + + [JsonPropertyName("nbformat")] + public int NbFormat { get; set; } + + [JsonPropertyName("nbformat_minor")] + public int NbFormatMinor { get; set; } + } + + private sealed class JupyterWriteMetadata + { + [JsonPropertyName("kernelspec")] + public JupyterKernelSpec? Kernelspec { get; set; } + + [JsonPropertyName("language_info")] + public JupyterLanguageInfo? LanguageInfo { get; set; } + } + + private sealed class JupyterKernelSpec + { + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("display_name")] + public string DisplayName { get; set; } = ""; + + [JsonPropertyName("language")] + public string Language { get; set; } = ""; + } + + private sealed class JupyterLanguageInfo + { + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + } + + private sealed class JupyterWriteCell + { + [JsonPropertyName("cell_type")] + public string CellType { get; set; } = "code"; + + [JsonPropertyName("metadata")] + public Dictionary Metadata { get; set; } = new(); + + [JsonPropertyName("source")] + public List Source { get; set; } = new(); + + [JsonPropertyName("execution_count")] + public int? ExecutionCount { get; set; } + + [JsonPropertyName("outputs")] + public List? Outputs { get; set; } + } + + private sealed class JupyterWriteOutput + { + [JsonPropertyName("output_type")] + public string OutputType { get; set; } = ""; + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("text")] + public List? Text { get; set; } + + [JsonPropertyName("data")] + public Dictionary>? Data { get; set; } + + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } + + [JsonPropertyName("ename")] + public string? EName { get; set; } + + [JsonPropertyName("evalue")] + public string? EValue { get; set; } + + [JsonPropertyName("traceback")] + public List? Traceback { get; set; } + } + + // --- Internal read DTOs --- private sealed class JupyterNotebook { diff --git a/tests/Verso.Cli.Tests/Repl/SaveMetaTests.cs b/tests/Verso.Cli.Tests/Repl/SaveMetaTests.cs new file mode 100644 index 0000000..0f7dd8f --- /dev/null +++ b/tests/Verso.Cli.Tests/Repl/SaveMetaTests.cs @@ -0,0 +1,121 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Spectre.Console.Testing; +using Verso.Abstractions; +using Verso.Cli.Repl; +using Verso.Cli.Repl.Meta; +using Verso.Cli.Repl.Meta.Commands; +using Verso.Cli.Repl.Rendering; +using Verso.Cli.Repl.Settings; +using Verso.Execution; +using Verso.Extensions; + +namespace Verso.Cli.Tests.Repl; + +[TestClass] +public class SaveMetaTests +{ + [TestMethod] + public async Task Save_NoArg_PreserveFormatTrue_KeepsIpynbExtension() + { + using var tmp = new TempIpynb(); + var session = await BuildSessionAsync(tmp.Path, preserveFormat: true); + var context = BuildContext(session); + var save = new SaveMeta(); + + await save.ExecuteAsync("", context, CancellationToken.None); + + Assert.IsTrue(File.Exists(tmp.Path), "Original .ipynb should still exist."); + Assert.AreEqual(tmp.Path, session.NotebookPath); + var written = await File.ReadAllTextAsync(tmp.Path); + Assert.IsTrue(written.Contains("\"nbformat\": 4"), "File should be re-serialized as nbformat 4."); + } + + [TestMethod] + public async Task Save_NoArg_PreserveFormatFalse_RewritesToVersoSibling() + { + using var tmp = new TempIpynb(); + var session = await BuildSessionAsync(tmp.Path, preserveFormat: false); + var context = BuildContext(session); + var save = new SaveMeta(); + + await save.ExecuteAsync("", context, CancellationToken.None); + + var expectedVerso = Path.ChangeExtension(tmp.Path, ".verso"); + Assert.IsTrue(File.Exists(expectedVerso), + $"Sibling .verso file expected at {expectedVerso}."); + Assert.AreEqual(expectedVerso, session.NotebookPath); + var written = await File.ReadAllTextAsync(expectedVerso); + Assert.IsTrue(written.Contains("\"verso\""), "Sibling file should be Verso-native JSON."); + + File.Delete(expectedVerso); + } + + [TestMethod] + public async Task Save_ExplicitIpynbPath_AlwaysHonored_RegardlessOfFlag() + { + using var tmp = new TempIpynb(); + var session = await BuildSessionAsync(tmp.Path, preserveFormat: false); + var context = BuildContext(session); + var save = new SaveMeta(); + + var explicitTarget = Path.Combine(Path.GetDirectoryName(tmp.Path)!, + $"explicit-{Guid.NewGuid():N}.ipynb"); + try + { + await save.ExecuteAsync(explicitTarget, context, CancellationToken.None); + + Assert.IsTrue(File.Exists(explicitTarget), + "Explicit .ipynb target must be honored even with preserve-format off."); + } + finally + { + if (File.Exists(explicitTarget)) File.Delete(explicitTarget); + } + } + + // --- helpers --- + + private static async Task BuildSessionAsync(string notebookPath, bool preserveFormat) + { + var host = new ExtensionHost(); + host.ConsentHandler = (_, _) => Task.FromResult(true); + await host.LoadBuiltInExtensionsAsync(); + + var notebook = new NotebookModel + { + Cells = { new CellModel { Type = "code", Language = "csharp", Source = "var x = 1;" } } + }; + var scaffold = new Scaffold(notebook, host, notebookPath); + + return new ReplSession(notebook, scaffold, host, notebookPath) + { + PreserveFormat = preserveFormat + }; + } + + private static MetaContext BuildContext(ReplSession session) + { + var console = new TestConsole(); + var renderer = new TerminalRenderer(console, useColor: false); + renderer.BindSettings(new ReplSettings()); + var registry = new MetaCommandRegistry(); + return new MetaContext(session, console, renderer, registry, useColor: false); + } + + private sealed class TempIpynb : IDisposable + { + public string Path { get; } + + public TempIpynb() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), + $"verso-save-test-{Guid.NewGuid():N}.ipynb"); + File.WriteAllText(Path, "{ \"nbformat\": 4, \"nbformat_minor\": 5, \"metadata\": {}, \"cells\": [] }"); + } + + public void Dispose() + { + if (File.Exists(Path)) File.Delete(Path); + } + } +} diff --git a/tests/Verso.Host.Tests/HandlerTests.cs b/tests/Verso.Host.Tests/HandlerTests.cs index 426a070..1cd32ee 100644 --- a/tests/Verso.Host.Tests/HandlerTests.cs +++ b/tests/Verso.Host.Tests/HandlerTests.cs @@ -294,7 +294,7 @@ public async Task Dispatch_UnknownMethod_ReturnsMethodNotFoundError() } [TestMethod] - public async Task NotebookSave_ReturnsVersoContent() + public async Task NotebookSave_NoParams_ReturnsVersoContent() { var (session, notebookId) = await CreateOpenSession(); var ns = GetNs(session, notebookId); @@ -302,10 +302,43 @@ public async Task NotebookSave_ReturnsVersoContent() new CellAddParams { Source = "Console.WriteLine(\"test\");" }, JsonRpcMessage.SerializerOptions)); - var result = await NotebookHandler.HandleSaveAsync(ns); + var result = await NotebookHandler.HandleSaveAsync(ns, null); Assert.IsFalse(string.IsNullOrWhiteSpace(result.Content)); Assert.IsTrue(result.Content.Contains("Console.WriteLine")); + Assert.IsTrue(result.Content.TrimStart().StartsWith("{"), "Verso format is JSON."); + Assert.IsTrue(result.Content.Contains("\"verso\""), "Verso format includes a 'verso' format-version field."); + } + + [TestMethod] + public async Task NotebookSave_JupyterFormat_ReturnsIpynbContent() + { + var (session, notebookId) = await CreateOpenSession(); + var ns = GetNs(session, notebookId); + CellHandler.HandleAdd(ns, JsonSerializer.SerializeToElement( + new CellAddParams { Source = "print hello" }, + JsonRpcMessage.SerializerOptions)); + + var paramsEl = JsonSerializer.SerializeToElement(new { format = "jupyter" }); + var result = await NotebookHandler.HandleSaveAsync(ns, paramsEl); + + using var doc = JsonDocument.Parse(result.Content); + Assert.AreEqual(4, doc.RootElement.GetProperty("nbformat").GetInt32()); + var source = doc.RootElement.GetProperty("cells")[0].GetProperty("source"); + Assert.AreEqual("print hello", source[0].GetString()); + } + + [TestMethod] + public async Task NotebookSave_UnknownFormat_FallsBackToVerso() + { + var (session, notebookId) = await CreateOpenSession(); + var ns = GetNs(session, notebookId); + + var paramsEl = JsonSerializer.SerializeToElement(new { format = "no-such-format" }); + var result = await NotebookHandler.HandleSaveAsync(ns, paramsEl); + + Assert.IsTrue(result.Content.Contains("\"verso\""), + "Unknown format should fall back to verso, not throw."); } [TestMethod] diff --git a/tests/Verso.Host.Tests/Handlers/PropertiesHandlerTests.cs b/tests/Verso.Host.Tests/Handlers/PropertiesHandlerTests.cs index 3a8798f..a0a2fa3 100644 --- a/tests/Verso.Host.Tests/Handlers/PropertiesHandlerTests.cs +++ b/tests/Verso.Host.Tests/Handlers/PropertiesHandlerTests.cs @@ -205,7 +205,7 @@ public async Task NotebookSaveOpen_RoundTripsCellDisplayMetadata() await PropertiesHandler.HandleUpdatePropertyAsync(ns, updateParams); } - var saved = await NotebookHandler.HandleSaveAsync(ns); + var saved = await NotebookHandler.HandleSaveAsync(ns, null); var reopenParams = JsonSerializer.SerializeToElement( new NotebookOpenParams { Content = saved.Content, FilePath = "roundtrip.verso" }, diff --git a/tests/Verso.Tests/Serializers/JupyterSerializerTests.cs b/tests/Verso.Tests/Serializers/JupyterSerializerTests.cs index 9c75b8e..52d4bea 100644 --- a/tests/Verso.Tests/Serializers/JupyterSerializerTests.cs +++ b/tests/Verso.Tests/Serializers/JupyterSerializerTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Verso.Serializers; namespace Verso.Tests.Serializers; @@ -34,13 +35,6 @@ public void CanImport_Verso_ReturnsFalse() Assert.IsFalse(_serializer.CanImport("notebook.verso")); } - [TestMethod] - public void SerializeAsync_ThrowsNotSupported() - { - Assert.ThrowsExceptionAsync( - () => _serializer.SerializeAsync(new NotebookModel())); - } - [TestMethod] public async Task Deserialize_CodeCell() { @@ -369,4 +363,223 @@ public async Task Deserialize_StreamOutput_SourceAsArray() Assert.AreEqual("line1\nline2\n", notebook.Cells[0].Outputs[0].Content); } + + // --- Serialize --- + + [TestMethod] + public async Task Serialize_EmptyNotebook_ProducesNbformat4() + { + var notebook = new NotebookModel(); + var json = await _serializer.SerializeAsync(notebook); + + using var doc = JsonDocument.Parse(json); + Assert.AreEqual(4, doc.RootElement.GetProperty("nbformat").GetInt32()); + Assert.AreEqual(5, doc.RootElement.GetProperty("nbformat_minor").GetInt32()); + Assert.AreEqual(0, doc.RootElement.GetProperty("cells").GetArrayLength()); + } + + [TestMethod] + public async Task Serialize_KernelSpec_FromDefaultKernel() + { + var notebook = new NotebookModel { DefaultKernelId = "csharp" }; + var json = await _serializer.SerializeAsync(notebook); + + using var doc = JsonDocument.Parse(json); + var kernelspec = doc.RootElement.GetProperty("metadata").GetProperty("kernelspec"); + Assert.AreEqual("csharp", kernelspec.GetProperty("name").GetString()); + Assert.AreEqual("C#", kernelspec.GetProperty("display_name").GetString()); + Assert.AreEqual("csharp", kernelspec.GetProperty("language").GetString()); + Assert.AreEqual("csharp", + doc.RootElement.GetProperty("metadata").GetProperty("language_info").GetProperty("name").GetString()); + } + + [TestMethod] + public async Task Serialize_MultiLineSource_AsStringList() + { + var notebook = new NotebookModel + { + Cells = { new CellModel { Type = "code", Source = "line1\nline2\nline3" } } + }; + var json = await _serializer.SerializeAsync(notebook); + + using var doc = JsonDocument.Parse(json); + var source = doc.RootElement.GetProperty("cells")[0].GetProperty("source"); + Assert.AreEqual(JsonValueKind.Array, source.ValueKind); + Assert.AreEqual(3, source.GetArrayLength()); + Assert.AreEqual("line1\n", source[0].GetString()); + Assert.AreEqual("line2\n", source[1].GetString()); + Assert.AreEqual("line3", source[2].GetString()); + } + + [TestMethod] + public async Task Serialize_StreamOutput_FromTextPlain() + { + var notebook = new NotebookModel + { + Cells = + { + new CellModel + { + Type = "code", + Source = "x", + Outputs = { new CellOutput("text/plain", "hello\n") } + } + } + }; + var json = await _serializer.SerializeAsync(notebook); + + using var doc = JsonDocument.Parse(json); + var output = doc.RootElement.GetProperty("cells")[0].GetProperty("outputs")[0]; + Assert.AreEqual("stream", output.GetProperty("output_type").GetString()); + Assert.AreEqual("stdout", output.GetProperty("name").GetString()); + Assert.AreEqual("hello\n", output.GetProperty("text")[0].GetString()); + } + + [TestMethod] + public async Task Serialize_ErrorOutput_PreservesENameAndTraceback() + { + var notebook = new NotebookModel + { + Cells = + { + new CellModel + { + Type = "code", + Source = "x", + Outputs = + { + new CellOutput( + "text/plain", + "bad value", + IsError: true, + ErrorName: "ValueError", + ErrorStackTrace: "frame 1\nframe 2") + } + } + } + }; + var json = await _serializer.SerializeAsync(notebook); + + using var doc = JsonDocument.Parse(json); + var output = doc.RootElement.GetProperty("cells")[0].GetProperty("outputs")[0]; + Assert.AreEqual("error", output.GetProperty("output_type").GetString()); + Assert.AreEqual("ValueError", output.GetProperty("ename").GetString()); + Assert.AreEqual("bad value", output.GetProperty("evalue").GetString()); + var tb = output.GetProperty("traceback"); + Assert.AreEqual(2, tb.GetArrayLength()); + Assert.AreEqual("frame 1", tb[0].GetString()); + Assert.AreEqual("frame 2", tb[1].GetString()); + } + + [TestMethod] + public async Task Serialize_DisplayData_FromImagePng() + { + var notebook = new NotebookModel + { + Cells = + { + new CellModel + { + Type = "code", + Source = "plot()", + Outputs = { new CellOutput("image/png", "iVBORw0KGgo=") } + } + } + }; + var json = await _serializer.SerializeAsync(notebook); + + using var doc = JsonDocument.Parse(json); + var output = doc.RootElement.GetProperty("cells")[0].GetProperty("outputs")[0]; + Assert.AreEqual("display_data", output.GetProperty("output_type").GetString()); + var data = output.GetProperty("data"); + Assert.AreEqual("iVBORw0KGgo=", data.GetProperty("image/png")[0].GetString()); + } + + [TestMethod] + public async Task Serialize_MarkdownCell_HasNoOutputs() + { + var notebook = new NotebookModel + { + Cells = { new CellModel { Type = "markdown", Source = "# Title" } } + }; + var json = await _serializer.SerializeAsync(notebook); + + using var doc = JsonDocument.Parse(json); + var cell = doc.RootElement.GetProperty("cells")[0]; + Assert.AreEqual("markdown", cell.GetProperty("cell_type").GetString()); + Assert.IsFalse(cell.TryGetProperty("outputs", out _)); + Assert.IsFalse(cell.TryGetProperty("execution_count", out _)); + } + + [TestMethod] + public async Task Serialize_NonStandardCellType_FallsBackToRaw() + { + var notebook = new NotebookModel + { + Cells = { new CellModel { Type = "sql", Source = "SELECT 1" } } + }; + var json = await _serializer.SerializeAsync(notebook); + + using var doc = JsonDocument.Parse(json); + var cell = doc.RootElement.GetProperty("cells")[0]; + Assert.AreEqual("raw", cell.GetProperty("cell_type").GetString()); + Assert.AreEqual("sql", cell.GetProperty("metadata").GetProperty("verso_type").GetString()); + } + + [TestMethod] + public async Task Serialize_ExecutionCount_FromMetadata() + { + var notebook = new NotebookModel + { + Cells = + { + new CellModel + { + Type = "code", + Source = "x = 1", + Metadata = { ["execution_count"] = 7 } + } + } + }; + var json = await _serializer.SerializeAsync(notebook); + + using var doc = JsonDocument.Parse(json); + var cell = doc.RootElement.GetProperty("cells")[0]; + Assert.AreEqual(7, cell.GetProperty("execution_count").GetInt32()); + Assert.IsFalse(cell.GetProperty("metadata").TryGetProperty("execution_count", out _), + "execution_count should not be duplicated in cell metadata."); + } + + [TestMethod] + public async Task RoundTrip_StreamOutput_PreservesContent() + { + var original = @"{ + ""nbformat"": 4, ""nbformat_minor"": 5, + ""metadata"": { ""kernelspec"": { ""language"": ""python"" } }, + ""cells"": [{ + ""cell_type"": ""code"", + ""source"": ""print('hi')"", + ""outputs"": [{ + ""output_type"": ""stream"", + ""name"": ""stdout"", + ""text"": ""hi\n"" + }], + ""metadata"": {}, + ""execution_count"": 3 + }] + }"; + + var notebook = await _serializer.DeserializeAsync(original); + var roundtripped = await _serializer.SerializeAsync(notebook); + var reread = await _serializer.DeserializeAsync(roundtripped); + + Assert.AreEqual(1, reread.Cells.Count); + Assert.AreEqual("code", reread.Cells[0].Type); + Assert.AreEqual("python", reread.Cells[0].Language); + Assert.AreEqual("print('hi')", reread.Cells[0].Source); + Assert.AreEqual(1, reread.Cells[0].Outputs.Count); + Assert.AreEqual("text/plain", reread.Cells[0].Outputs[0].MimeType); + Assert.AreEqual("hi\n", reread.Cells[0].Outputs[0].Content); + Assert.AreEqual(3, Convert.ToInt32(reread.Cells[0].Metadata["execution_count"])); + } } diff --git a/vscode/package.json b/vscode/package.json index a82c19b..ee7b8fc 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -64,6 +64,11 @@ "type": "string", "default": "", "description": "Directory of third-party Verso extension assemblies to load on notebook open. Changes apply on next notebook open or kernel restart." + }, + "verso.preserveOriginalFormat": { + "type": "boolean", + "default": false, + "description": "When opening an .ipynb file, save changes back to .ipynb instead of converting to .verso. Cell outputs are preserved. Default off keeps the existing convert-on-save behavior." } } }, diff --git a/vscode/src/blazor/blazorEditorProvider.ts b/vscode/src/blazor/blazorEditorProvider.ts index 87198c5..b33b56e 100644 --- a/vscode/src/blazor/blazorEditorProvider.ts +++ b/vscode/src/blazor/blazorEditorProvider.ts @@ -371,6 +371,13 @@ export class BlazorEditorProvider // --- Save / Revert --- + // Extensions Verso can write back in their original format. Anything not listed + // here falls through to the convert-to-.verso path even when the + // verso.preserveOriginalFormat setting is on. + private static readonly preservableFormats: Record = { + ".ipynb": "jupyter", + }; + async saveCustomDocument( document: VersoDocument, _cancellation: vscode.CancellationToken @@ -380,10 +387,16 @@ export class BlazorEditorProvider if (!session || !notebookId) return; const host = session.host; - // If the source file is not a .verso file (e.g. imported .dib or .ipynb), - // redirect the save to a .verso file to avoid overwriting the original format. const fsPath = document.uri.fsPath; - if (!fsPath.endsWith(".verso")) { + const ext = path.extname(fsPath).toLowerCase(); + const preserveSetting = vscode.workspace.getConfiguration("verso") + .get("preserveOriginalFormat", false); + const preservableFormat = BlazorEditorProvider.preservableFormats[ext]; + const shouldPreserve = ext !== ".verso" && preserveSetting && preservableFormat !== undefined; + + // Source isn't .verso and the user hasn't opted in to preservation (or the + // format isn't writable back): redirect to a sibling .verso file. + if (ext !== ".verso" && !shouldPreserve) { const versoPath = fsPath.replace(/\.[^.]+$/, ".verso"); const versoUri = vscode.Uri.file(versoPath); @@ -406,9 +419,10 @@ export class BlazorEditorProvider return; } + const format = ext === ".verso" ? "verso" : preservableFormat; const result = await host.sendRequest( "notebook/save", - { notebookId } + { notebookId, format } ); const data = new TextEncoder().encode(result.content); await vscode.workspace.fs.writeFile(document.uri, data); @@ -424,16 +438,27 @@ export class BlazorEditorProvider if (!session || !notebookId) return; const host = session.host; - // If the destination is not a .verso file, adjust the extension + const destExt = path.extname(destination.fsPath).toLowerCase(); + const preserveSetting = vscode.workspace.getConfiguration("verso") + .get("preserveOriginalFormat", false); + const preservableFormat = BlazorEditorProvider.preservableFormats[destExt]; + const shouldPreserve = destExt !== ".verso" && preserveSetting && preservableFormat !== undefined; + let targetUri = destination; - if (!destination.fsPath.endsWith(".verso")) { + let format = "verso"; + if (destExt === ".verso") { + format = "verso"; + } else if (shouldPreserve) { + format = preservableFormat; + } else { + // Destination isn't writable in its requested format: coerce to .verso. const versoPath = destination.fsPath.replace(/\.[^.]+$/, ".verso"); targetUri = vscode.Uri.file(versoPath); } const result = await host.sendRequest( "notebook/save", - { notebookId } + { notebookId, format } ); const data = new TextEncoder().encode(result.content); await vscode.workspace.fs.writeFile(targetUri, data); From 3b4dc931cdd2cc7cd11ee9e6dd9c3b3fc16febf4 Mon Sep 17 00:00:00 2001 From: Torrey Betts Date: Sat, 23 May 2026 20:45:54 -0400 Subject: [PATCH 2/3] Add opt-in .ipynb round-trip (preserve format) Introduce opt-in support for round-tripping Jupyter .ipynb files instead of always converting to .verso. Update serializer metadata to mark Jupyter as writable, add a VS Code setting (verso.preserveOriginalFormat) and CLI flags (--preserve-format) for verso repl/serve to enable in-place .ipynb saves, and make front-ends send an optional format parameter on notebook/save. Documentation and READMEs updated to explain default behavior (sibling .verso created) and the opt-in preservation semantics, including limitations and how non-native Verso features are represented when round-tripping. --- README.md | 4 ++-- docs/architecture/engine.md | 4 ++-- docs/architecture/front-ends.md | 2 +- docs/migration/from-jupyter.md | 4 ++-- src/Verso.Cli/README.md | 2 ++ vscode/README.md | 6 ++++++ 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ad0f469..f5db913 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Three built-in themes (Light, Dark, High Contrast) are hot-swappable at runtime. ### Import from Jupyter and Polyglot Notebooks -Open any `.ipynb` or `.dib` file and Verso converts it automatically. Polyglot Notebook patterns like `#!fsharp`, `#!connect`, and `#!sql` are mapped to native Verso cells during import. Saving writes to a `.verso` file, preserving the original. +Open any `.ipynb` or `.dib` file and Verso converts it automatically. Polyglot Notebook patterns like `#!fsharp`, `#!connect`, and `#!sql` are mapped to native Verso cells during import. By default, saving writes to a sibling `.verso` file and leaves the original untouched. To save `.ipynb` notebooks back to `.ipynb` (cell outputs preserved), enable the `verso.preserveOriginalFormat` setting in VS Code, or pass `--preserve-format` to `verso repl` / `verso serve`. ## Extension Model @@ -147,7 +147,7 @@ Verso includes a `dotnet new` template, a testing library (`Verso.Testing`), and | **Magic Commands** | `#!time`, `#!nuget`, `#!pip`, `#!npm`, `#!extension`, `#!restart`, `#!about`, `#!import`, `#!sql-connect`, `#!sql-disconnect`, `#!sql-schema`, `#!sql-scaffold`, `#!http-set-base`, `#!http-set-header`, `#!http-set-timeout` | | **Toolbar Actions** | Run Cell, Run All, Clear Outputs, Restart, Switch Layout, Switch Theme, Export HTML, Export Markdown | | **Data Formatters** | Primitives, Collections (HTML tables), HTML, Images, SVG, Exceptions, F# types, SQL result sets | -| **Serializers** | `.verso` (native JSON), `.ipynb` import, `.dib` import | +| **Serializers** | `.verso` (native JSON, read/write), `.ipynb` (read/write, write opt-in), `.dib` (read only) | ## The `.verso` File Format diff --git a/docs/architecture/engine.md b/docs/architecture/engine.md index fb2a188..3c53f77 100644 --- a/docs/architecture/engine.md +++ b/docs/architecture/engine.md @@ -198,10 +198,10 @@ Notebooks are serialized and deserialized through the `INotebookSerializer` inte | Serializer | Format ID | Extensions | Read | Write | |------------|-----------|------------|:----:|:-----:| | `VersoSerializer` | `verso.serializer.verso` | `.verso` | Yes | Yes | -| `JupyterSerializer` | `verso.serializer.jupyter` | `.ipynb` | Yes | No | +| `JupyterSerializer` | `verso.serializer.jupyter` | `.ipynb` | Yes | Yes | | `DibSerializer` | `verso.serializer.dib` | `.dib` | Yes | No | -The `.verso` format is native JSON. Jupyter and Polyglot Notebook (`.dib`) files are import-only. Saving always writes `.verso` format. +The `.verso` format is native JSON. Polyglot Notebook (`.dib`) files are import-only. Jupyter (`.ipynb`) files round-trip when the host receives `format: "jupyter"` on `notebook/save`; otherwise the host writes the native `.verso` format. Front-ends expose this opt-in through the `verso.preserveOriginalFormat` VS Code setting and the `--preserve-format` flag on `verso repl` and `verso serve`. When preservation is off, opening a non-`.verso` notebook and saving produces a sibling `.verso` file. The `VersoSerializer` intentionally omits parameters cell outputs during serialization because those outputs are always re-rendered from `metadata.parameters` at display time. diff --git a/docs/architecture/front-ends.md b/docs/architecture/front-ends.md index f21c36e..1097222 100644 --- a/docs/architecture/front-ends.md +++ b/docs/architecture/front-ends.md @@ -123,7 +123,7 @@ When a `.verso` file is opened: Each open notebook gets its own host process and bridge. When the editor panel is closed, the host process is terminated. -Save operations flow through the host: `saveCustomDocument` sends `notebook/save` to the host, receives serialized content, and writes it via `vscode.workspace.fs.writeFile`. +Save operations flow through the host: `saveCustomDocument` sends `notebook/save` to the host, receives serialized content, and writes it via `vscode.workspace.fs.writeFile`. The request carries an optional `format` parameter (default `"verso"`) that selects the serializer by `FormatId`. When the `verso.preserveOriginalFormat` setting is enabled and the document's extension matches a writable non-native serializer (currently `.ipynb`), the editor sends `format: "jupyter"` so the file round-trips in place; otherwise it falls back to the original convert-to-`.verso` behavior. ### HostProcess diff --git a/docs/migration/from-jupyter.md b/docs/migration/from-jupyter.md index 9c773db..911f15b 100644 --- a/docs/migration/from-jupyter.md +++ b/docs/migration/from-jupyter.md @@ -28,7 +28,7 @@ The original file is not modified. Use `--output` to specify a different output verso convert notebook.ipynb --to verso --output cleaned.verso --strip-outputs ``` -Saving an imported notebook in Verso always writes to a new `.verso` file, preserving the original. +By default, saving an imported notebook in Verso writes to a sibling `.verso` file and leaves the original untouched. To save back to `.ipynb` with cell outputs preserved, enable the `verso.preserveOriginalFormat` setting in VS Code, or pass `--preserve-format` to `verso repl` / `verso serve`. The flag is off by default to keep existing convert-on-save behavior for users who rely on it. ## What Gets Converted @@ -149,7 +149,7 @@ Jupyter uses a kernel/server extension model. Verso's extension model is based o ## Limitations -- **Export back to `.ipynb` is not supported.** Conversion is one-way. If you need to maintain a Jupyter-compatible version, keep the original `.ipynb` file. +- **Round-trip to `.ipynb` is opt-in and lossy for non-standard cell types.** With `verso.preserveOriginalFormat` (or `--preserve-format`) on, `.ipynb` files save back as `.ipynb` with code, markdown, and cell outputs preserved. Verso-specific cell types (HTML, Mermaid, SQL, HTTP) are written as Jupyter `raw` cells with the original type stashed in `cell.metadata.verso_type`; notebook-level features like layouts, parameter definitions, and theme preferences are not represented in the Jupyter format. - **Jupyter notebook format versions below 4 are not supported.** Notebooks created with very old versions of Jupyter (nbformat 1-3) need to be upgraded to nbformat 4 first (open and re-save in JupyterLab). - **IPython magics are not converted.** Cell and line magics (`%`, `%%`) are Python kernel-specific and pass through as literal text. - **Jupyter widgets are not supported.** Interactive widgets (`ipywidgets`) do not have a Verso equivalent. Static output from widgets (HTML snapshots) may be preserved in cell outputs. diff --git a/src/Verso.Cli/README.md b/src/Verso.Cli/README.md index c2fabbf..0c5472c 100644 --- a/src/Verso.Cli/README.md +++ b/src/Verso.Cli/README.md @@ -45,6 +45,7 @@ verso serve --no-browser | `--no-https` | false | Serve over HTTP only | | `--extensions ` | none | Additional directory to scan for extension assemblies | | `--verbose` | false | Enable detailed startup logging | +| `--preserve-format` | false | When a `.ipynb` notebook is loaded, save back to `.ipynb` (cell outputs preserved) instead of converting to `.verso` | ### `verso run` @@ -130,6 +131,7 @@ verso repl --list-themes | `--history \|none` | platform state dir | Override or disable persistent history | | `--list-kernels` | false | Print registered kernels and exit | | `--list-themes` | false | Print registered themes and exit | +| `--preserve-format` | false | When a `.ipynb` notebook is loaded, `.save` with no argument writes back to `.ipynb` instead of converting to `.verso` | **Submitting cells.** In the full-featured (PrettyPrompt) driver, `Enter` inserts a newline unless the buffer already ends with two trailing newlines — a third `Enter` submits. Meta-commands (leading `.`) submit on the first `Enter`. In `--plain` mode, a blank line submits and `;;` on its own line force-submits. diff --git a/vscode/README.md b/vscode/README.md index 4091144..5121124 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -99,6 +99,12 @@ The Python kernel requires **Python 3.8-3.12** installed on your system. Python To import an existing notebook, use **File > Open** on any `.ipynb` or `.dib` file. +### Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| `verso.preserveOriginalFormat` | `false` | When opening an `.ipynb` file, save changes back to `.ipynb` (cell outputs preserved) instead of converting to a sibling `.verso` file. Leave off to keep the existing convert-on-save behavior. | + ## Supported Languages | Language | IntelliSense | Variable Sharing | From 4cc3244d7b8ba9790dfbd00ac025bb8b6480ec85 Mon Sep 17 00:00:00 2001 From: Torrey Betts Date: Sun, 24 May 2026 09:16:11 -0400 Subject: [PATCH 3/3] Guard save params; treat empty MIME as text/plain Prevent TryGetProperty from running on non-object JsonElement values in NotebookHandler.HandleSaveAsync by pattern-matching params as an object first, ensuring non-object (null/array/primitive) params fall back to the default "verso" format. Also update JupyterSerializer to treat null/empty MimeType as "text/plain" (avoiding invalid empty MIME keys) and use the coerced mime type when emitting display_data. Added unit tests to cover non-object save params and empty MimeType behavior. --- src/Verso.Host/Handlers/NotebookHandler.cs | 3 ++- src/Verso/Serializers/JupyterSerializer.cs | 8 +++++-- tests/Verso.Host.Tests/HandlerTests.cs | 21 ++++++++++++++++ .../Serializers/JupyterSerializerTests.cs | 24 +++++++++++++++++++ 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/Verso.Host/Handlers/NotebookHandler.cs b/src/Verso.Host/Handlers/NotebookHandler.cs index 6cfd2f8..f299e7d 100644 --- a/src/Verso.Host/Handlers/NotebookHandler.cs +++ b/src/Verso.Host/Handlers/NotebookHandler.cs @@ -211,7 +211,8 @@ public static object HandleSetDefaultKernel(NotebookSession ns, JsonElement? @pa public static async Task HandleSaveAsync(NotebookSession ns, JsonElement? @params) { var format = "verso"; - if (@params?.TryGetProperty("format", out var fmtEl) == true + if (@params is { ValueKind: JsonValueKind.Object } p + && p.TryGetProperty("format", out var fmtEl) && fmtEl.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(fmtEl.GetString())) { diff --git a/src/Verso/Serializers/JupyterSerializer.cs b/src/Verso/Serializers/JupyterSerializer.cs index d01a7f7..6471082 100644 --- a/src/Verso/Serializers/JupyterSerializer.cs +++ b/src/Verso/Serializers/JupyterSerializer.cs @@ -168,7 +168,11 @@ private static JupyterWriteOutput BuildOutput(CellOutput output) }; } - if (string.Equals(output.MimeType, "text/plain", StringComparison.OrdinalIgnoreCase)) + // Null/empty MimeType is treated as text/plain rather than producing a + // display_data with an invalid empty MIME key. + var mimeType = string.IsNullOrEmpty(output.MimeType) ? "text/plain" : output.MimeType; + + if (string.Equals(mimeType, "text/plain", StringComparison.OrdinalIgnoreCase)) { return new JupyterWriteOutput { @@ -183,7 +187,7 @@ private static JupyterWriteOutput BuildOutput(CellOutput output) OutputType = "display_data", Data = new Dictionary> { - [output.MimeType] = SplitSourceForJupyter(output.Content ?? "") + [mimeType] = SplitSourceForJupyter(output.Content ?? "") }, Metadata = new Dictionary() }; diff --git a/tests/Verso.Host.Tests/HandlerTests.cs b/tests/Verso.Host.Tests/HandlerTests.cs index 1cd32ee..03607d0 100644 --- a/tests/Verso.Host.Tests/HandlerTests.cs +++ b/tests/Verso.Host.Tests/HandlerTests.cs @@ -341,6 +341,27 @@ public async Task NotebookSave_UnknownFormat_FallsBackToVerso() "Unknown format should fall back to verso, not throw."); } + [TestMethod] + public async Task NotebookSave_NonObjectParams_FallsBackToVerso() + { + var (session, notebookId) = await CreateOpenSession(); + var ns = GetNs(session, notebookId); + + // JSON null, an array, and a primitive are all legal JSON-RPC params shapes; + // none should cause TryGetProperty to throw. + var jsonNull = JsonDocument.Parse("null").RootElement; + var jsonArray = JsonDocument.Parse("[1,2,3]").RootElement; + var jsonNumber = JsonDocument.Parse("42").RootElement; + + var nullResult = await NotebookHandler.HandleSaveAsync(ns, jsonNull); + var arrayResult = await NotebookHandler.HandleSaveAsync(ns, jsonArray); + var numberResult = await NotebookHandler.HandleSaveAsync(ns, jsonNumber); + + Assert.IsTrue(nullResult.Content.Contains("\"verso\"")); + Assert.IsTrue(arrayResult.Content.Contains("\"verso\"")); + Assert.IsTrue(numberResult.Content.Contains("\"verso\"")); + } + [TestMethod] public async Task CellMove_ReordersCells() { diff --git a/tests/Verso.Tests/Serializers/JupyterSerializerTests.cs b/tests/Verso.Tests/Serializers/JupyterSerializerTests.cs index 52d4bea..46cb48f 100644 --- a/tests/Verso.Tests/Serializers/JupyterSerializerTests.cs +++ b/tests/Verso.Tests/Serializers/JupyterSerializerTests.cs @@ -435,6 +435,30 @@ public async Task Serialize_StreamOutput_FromTextPlain() Assert.AreEqual("hello\n", output.GetProperty("text")[0].GetString()); } + [TestMethod] + public async Task Serialize_EmptyMimeType_TreatsAsTextPlain() + { + var notebook = new NotebookModel + { + Cells = + { + new CellModel + { + Type = "code", + Source = "x", + Outputs = { new CellOutput("", "hello") } + } + } + }; + var json = await _serializer.SerializeAsync(notebook); + + using var doc = JsonDocument.Parse(json); + var output = doc.RootElement.GetProperty("cells")[0].GetProperty("outputs")[0]; + Assert.AreEqual("stream", output.GetProperty("output_type").GetString(), + "Empty MimeType should coerce to text/plain stream rather than emit an empty MIME key."); + Assert.AreEqual("stdout", output.GetProperty("name").GetString()); + } + [TestMethod] public async Task Serialize_ErrorOutput_PreservesENameAndTraceback() {