diff --git a/MLS.Agent.Tools/RelativePath.cs b/MLS.Agent.Tools/RelativePath.cs index 239fee6e6..1b6533fbe 100644 --- a/MLS.Agent.Tools/RelativePath.cs +++ b/MLS.Agent.Tools/RelativePath.cs @@ -189,4 +189,9 @@ protected static void ThrowIfContainsDisallowedFilePathChars(string filename) // return !Equals(left, right); // } } + + public static class ArrayExtensions + { + + } } \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Jupyter.Tests/MagicCommandTests.cs b/Microsoft.DotNet.Interactive.Jupyter.Tests/MagicCommandTests.cs index 766c0115c..728ae8f3a 100644 --- a/Microsoft.DotNet.Interactive.Jupyter.Tests/MagicCommandTests.cs +++ b/Microsoft.DotNet.Interactive.Jupyter.Tests/MagicCommandTests.cs @@ -3,6 +3,7 @@ using System; using System.CommandLine; +using System.IO; using System.Linq; using System.Threading.Tasks; using FluentAssertions; @@ -167,7 +168,7 @@ public async Task markdown_renders_markdown_content_as_html() await kernel.SendAsync(new SubmitCode( $"%%markdown\n\n# Topic!\nContent")); - + var formatted = events @@ -220,5 +221,105 @@ await kernel.SendAsync(new SubmitCode( .Should() .Contain(v => v.Value.Equals("done!")); } + + [Fact] + public async Task WriteFile_magic_command_writes_the_code_in_a_file() + { + var kernel = new CompositeKernel + { + new CSharpKernel().UseKernelHelpers() + } + .UseDefaultMagicCommands() + .LogEventsToPocketLogger(); + + using var events = kernel.KernelEvents.ToSubscribedList(); + + var file = Path.GetTempFileName(); + var text = "This text must be written to a file"; + await kernel.SendAsync(new SubmitCode( + $@" +%%writefile {file} +{text}")); + + File.ReadAllText(file).Should().Be(text); + + events.Should() + .ContainSingle(e => e is DisplayedValueProduced) + .Which + .As() + .FormattedValues + .Should() + .ContainSingle(v => + v.MimeType == "text/plain" && + v.Value.ToString().StartsWith($"Overwriting text to file {file}")); + + } + + [Fact] + public async Task WriteFile_magic_command_appends_the_code_to_file_when_option_is_set() + { + var kernel = new CompositeKernel + { + new CSharpKernel().UseKernelHelpers() + } + .UseDefaultMagicCommands() + .LogEventsToPocketLogger(); + + using var events = kernel.KernelEvents.ToSubscribedList(); + + var file = Path.GetTempFileName(); + File.Create(file).Dispose(); + var existingText = "This text is already there"; + File.WriteAllText(file, existingText); + + var appendedText = "This text must be written to a file"; + await kernel.SendAsync(new SubmitCode( +$@"%%writefile -a {file} +{appendedText}")); + + File.ReadAllText(file).Should().Be(existingText + "\n" + appendedText); + + events.Should() + .ContainSingle(e => e is DisplayedValueProduced) + .Which + .As() + .FormattedValues + .Should() + .ContainSingle(v => + v.MimeType == "text/plain" && + v.Value.ToString().StartsWith($"Appending text to file {file}")); + + } + + [Fact(Skip = "Needs https://github.com/dotnet/try/issues/469 to be resolved ")] + public async Task WriteFile_magic_command_shows_error_if_filename_is_invalid() + { + var kernel = new CompositeKernel + { + new CSharpKernel().UseKernelHelpers() + } + .UseDefaultMagicCommands() + .LogEventsToPocketLogger(); + + using var events = kernel.KernelEvents.ToSubscribedList(); + + var file = "INVALID_PATH"; + var text = "This text must be not be written to a file"; + await kernel.SendAsync(new SubmitCode( + $@" +%%writefile {file} +{text}")); + + events.Should() + .ContainSingle(e => e is DisplayedValueProduced) + .Which + .As() + .FormattedValues + .Should() + .ContainSingle(v => + v.MimeType == "text/plain" && + v.Value.ToString().StartsWith($"Could not write to file")); + + } } } diff --git a/Microsoft.DotNet.Interactive.Jupyter/KernelExtensions.cs b/Microsoft.DotNet.Interactive.Jupyter/KernelExtensions.cs index bb1fb4371..fcc2e5ce5 100644 --- a/Microsoft.DotNet.Interactive.Jupyter/KernelExtensions.cs +++ b/Microsoft.DotNet.Interactive.Jupyter/KernelExtensions.cs @@ -1,6 +1,8 @@ // Copyright (c) .NET Foundation and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System; +using System.Collections.Generic; using System.CommandLine; using System.CommandLine.Invocation; using System.Diagnostics; @@ -14,6 +16,8 @@ using Microsoft.DotNet.Interactive.Events; using Microsoft.DotNet.Interactive.Extensions; using Microsoft.DotNet.Interactive.Rendering; +using MLS.Agent.Tools; +using MLS.Agent.Tools.Roslyn; using static Microsoft.DotNet.Interactive.Rendering.PocketViewTags; namespace Microsoft.DotNet.Interactive.Jupyter @@ -27,7 +31,8 @@ public static T UseDefaultMagicCommands(this T kernel) .UseHtml() .UseJavaScript() .UseMarkdown() - .UseTime(); + .UseTime() + .UseWriteFile(); return kernel; } @@ -37,25 +42,25 @@ private static T UseHtml(this T kernel) { kernel.AddDirective(new Command("%%html") { - Handler = CommandHandler.Create((KernelInvocationContext context) => - { - if (context.Command is SubmitCode submitCode) - { - var htmlContent = submitCode.Code - .Replace("%%html", "") - .Trim(); - - context.Publish(new DisplayedValueProduced( - htmlContent, - context.Command, - formattedValues: new[] - { + Handler = CommandHandler.Create((KernelInvocationContext context) => + { + if (context.Command is SubmitCode submitCode) + { + var htmlContent = submitCode.Code + .Replace("%%html", "") + .Trim(); + + context.Publish(new DisplayedValueProduced( + htmlContent, + context.Command, + formattedValues: new[] + { new FormattedValue("text/html", htmlContent) - })); + })); - context.Complete(); - } - }) + context.Complete(); + } + }) }); return kernel; @@ -126,6 +131,14 @@ private static T UseTime(this T kernel) return kernel; } + private static T UseWriteFile(this T kernel) + where T : KernelBase + { + kernel.AddDirective(writefile()); + + return kernel; + } + private static T UseLsMagic(this T kernel) where T : KernelBase { @@ -236,7 +249,7 @@ await context.CurrentKernel.SendAsync( context.Publish( new DisplayedValueProduced( - elapsed, + elapsed, context.Command, new[] { @@ -248,5 +261,80 @@ await context.CurrentKernel.SendAsync( }) }; } + + private static Command writefile() + { + var command = new Command("%%writefile") + { + Handler = CommandHandler.Create(async (KernelInvocationContext context, WriteFileOptions options) => + { + if (context.Command is SubmitCode submitCode) + { + var code = string.Join("\n", submitCode.Code.Split('\n', '\r').Where(line => !line.Contains("%%writefile"))); + + try + { + if (!options.FileName.Exists) + { + options.FileName.Create().Dispose(); + } + + string message; + if (options.Append) + { + File.AppendAllText(options.FileName.FullName, code); + message = $"Appending text to file {options.FileName.FullName}"; + } + else + { + File.WriteAllText(options.FileName.FullName, code); + message = $"Overwriting text to file {options.FileName.FullName}"; + } + await context.HandlingKernel.SendAsync(new DisplayValue(message, new FormattedValue(PlainTextFormatter.MimeType, message))); + } + catch (Exception e) + { + var formattableString = $"Could not write to file {options.FileName.FullName} due to exception {e.Message}"; + await context.HandlingKernel.SendAsync(new DisplayValue(formattableString, new FormattedValue(PlainTextFormatter.MimeType, formattableString))); + } + + context.Complete(); + } + }) + }; + + var fileArgument = new Argument() + { + Name = nameof(WriteFileOptions.FileName), + Description = "Specify the file path to write to" + }; + + fileArgument.AddValidator(symbolResult => + { + var file = symbolResult.Tokens + .Select(t => t.Value) + .FirstOrDefault(); + + if (!PathUtilities.IsAbsolute(file)) + { + return "Absolute file path expected"; + } + + if (!PathUtilities.IsValidFilePath(file)) + { + return "Invalid file path"; + } + + return null; + }); + + command.AddArgument(fileArgument); + command.AddOption(new Option(new string[] { "-a", "--append" }, "Append to file") + { + Argument = new Argument(defaultValue: () => false) + }); + + return command; + } } } \ No newline at end of file diff --git a/Microsoft.DotNet.Interactive.Jupyter/WriteFileOptions.cs b/Microsoft.DotNet.Interactive.Jupyter/WriteFileOptions.cs new file mode 100644 index 000000000..1f71181fd --- /dev/null +++ b/Microsoft.DotNet.Interactive.Jupyter/WriteFileOptions.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.IO; + +namespace Microsoft.DotNet.Interactive.Jupyter +{ + internal class WriteFileOptions + { + public WriteFileOptions(FileInfo fileName, bool a) + { + FileName = fileName ?? throw new ArgumentNullException(nameof(fileName)); + Append = a; + } + + public FileInfo FileName { get; set; } + public bool Append { get; } + } +} \ No newline at end of file