Skip to content

Latest commit

 

History

History
394 lines (268 loc) · 21.2 KB

formatting.md

File metadata and controls

394 lines (268 loc) · 21.2 KB

Formatting

When using a tool backed by .NET Interactive (including Polyglot Notebooks, Jupyter, and others), the output you typically see is produced using .NET Interactive formatters, a set of APIs under the Microsoft.DotNet.Interactive.Formatting namespace. (These APIs are available in a NuGet package that can be used independently of notebooks.) Formatters create string representations of objects. These string representations can vary from plain text to HTML to machine-readable formats like JSON and CSV. The following are examples of code you can write in a notebook that result in objects being formatted for display:

  • A return statement or trailing expression at the end of a C# cell.
  • A trailing expression at the end of an F# cell.
  • A call to the Display and ToDisplayString extension methods, available for all objects in C# and F#.
  • A call to Out-Display in a PowerShell cell.

Formatters are also used to format the output you see for .NET objects in the Polyglot Notebooks Variables View. (Formatting of values in other languages doesn't rely on .NET).

The term "formatting" refers to the process of creating a string representation of an object. This is done by the .NET Interactive kernel using the APIs described here. When a formatted string is then displayed in a notebook in VS Code or JupyterLab, that's referred to as "rendering."

MIME Types and Display

For any given object, many different string representations are possible. These different representations have associated MIME types, identified by short strings such as text/html or application/json. MIME types can be used to request specific formatting for an object using the Display extension method, which you can call with any object. In this example, we can display a Rectangle object assigned to variable rect by calling rect.Display():

Note that the default MIME type in Polyglot Notebooks is text/html. This can vary from one .NET type to another, but in the example above, no custom settings have been applied for the Rectangle type. (We'll show more about how to do that below.)

Note: For a cell's return value in C# or F#, only the formatter for the default MIME type can be used.

You can also specify a different MIME type than the default when using Display. To do this, you simply pass the desired MIME type as a parameter, for example: rect.Display("text/plain").

image

Another MIME type that's generally available is application/json. When using this MIME type in Polyglot Notebooks, the object is formatted using System.Text.Json.

image

In the above screen shot, the code coloring in the JSON output is provided by the VS Code notebook renderer for application/json.

Configuring formatting

The .NET Interactive formatting APIs are highly configurable. The next section describes the various ways that you can change how formatting behaves in notebooks or in any own code that uses Microsoft.DotNet.Interactive.Formatting directly.

Limiting list output

When formatting sequences, such as arrays or objects implementing IEnumerable, .NET Interactive's formatters will expand them so that you can see the values. The following example calls DirectoryInfo(".").GetFileSystemInfos() to display the files and directories under the current directory:

image

At the end of the displayed list is the text (31 more). Twenty items were displayed after which the formatter stopped and showed the remaining count. If you'd like to see more or fewer items in the output, this limit can be configured both globally and per-.NET type.

To change this limit globally so that it applies to objects of any type, you can set Formatter.ListExpansionLimit.

image

In this example, by setting Formatter.ListExpansionLimit = 5 and then displaying the same file list, .NET Interactive now displays only the first five items, followed by (46 more).

You can similarly limit output for a specific type by setting Formatter<T>.ListExpansionLimit. Note that the type T here must be an exact match for the items in the list. Here's an example using int:

Formatter<int>.ListExpansionLimit = 3;
Enumerable.Range(1, 10)

This produces the following output:

[ 1, 2, 3 ... (more) ]

You'll note that this sequence ends with (more) rather than (7 more). This is because the return type of the Enumerable.Range call is IEnumerable<int> and so its count can't be known without enumerating the entire sequence. In this case, the .NET Interactive formatter stops when it reaches the configured ListExpansionLimit and doesn't count the rest of the sequence.

Limiting object graph recursion

It's common for object graphs to contain reference cycles. The .NET Interactive formatter will traverse object graphs but in order to avoid both oversized outputs and possible infinite recursion when there is a reference cycle, the formatter will only recurse to a specific depth.

Consider the following C# code, which defines a simple Node class, creates a reference cycle, and formats it using a C# Script trailing expression (which is equivalent to a return statement):

public class Node
{
    public Node Next { get; set; } 
}

Node node1 = new();
Node node2 = new();

node1.Next = node2;
node2.Next = node1;

node1

This code produces the following output (presented here without the Polyglot Notebooks styling):

Submission#3+Node
Next
Submission#3+Node
Next
Submission#3+Node
Next
Submission#3+Node
Next
Submission#3+Node
Next
Submission#3+Node
NextSubmission#3+Node

What this shows is that the formatter stopped recursing after formatting to a depth of 6. This depth can be changed using the Formatter.RecursionLimit method:

Formatter.RecursionLimit = 2;
node1

Running this code now produces this shorter output:

Submission#3+Node
Next
Submission#3+Node
NextSubmission#3+Node

Preferred MIME types

We mentioned above that the default MIME type used for formatting in Polyglot Notebooks is text/html. This default is applied when using the Display() method without passing a value to the mimeType parameter, or when using a return statement or trailing expression in C# or F#. This default can be changed globally or for a specific type.

The following example changes the default for Rectangle to text/plain.

using System.Drawing;
using Microsoft.DotNet.Interactive.Formatting;

Formatter.SetPreferredMimeTypesFor(typeof(Rectangle), "text/plain");

new Rectangle
{   
    Height = 50, 
    Width = 100
}
Rectangle
  Location: Point
    IsEmpty: True
    X: 0
    Y: 0
  Size: Size
    IsEmpty: False
    Width: 100
    Height: 50
  X: 0
  Y: 0
  Width: 100
  Height: 50
  Left: 0
  Top: 0
  Right: 100
  Bottom: 50
  IsEmpty: False

You'll also notice that the method can be used to set more than one preferred MIME type. The second parameter is a params parameter which allows you to pass multiple values.

Formatter.SetPreferredMimeTypesFor(
  typeof(Rectangle), 
  "text/plain",
  "application/json");

If you click on the ... (More Actions...) button to the left of the output and select Change presentation, you'll be given a choice of notebook renderer that you can use to display the different outputs in different ways.

Replacing the default formatting for a type

The default formatters typically show the values of the objects being displayed by printing lists and properties. The output is mostly textual. If you would like to see something different for a given type, whether a different textual output or an image or a plot, you can do this by register custom formatters for specific types. These can be types you defined or types defined in other .NET libraries. One common case where a custom formatter is used is when rendering plots. Some NuGet packages, such as Plotly.NET, carry .NET Interactive extensions that use this feature to provide interactive HTML- and JavaScript-based outputs.

The simplest method for registering a custom formatter is Formatter.Register<T>, which has a few different overloads. The friendliest one for use in a notebook accepts two arguments:

  • A delegate that takes an object of the the type you want to register and returns a string. Here, you can specify the string transformation you need.
  • A MIME type. Your custom formatter will only be called whenthis MIME type is being used.

The following example formats instances of System.Drawing.Rectangle as a SVG rectangles.

Formatter.Register<Rectangle>(
    rect => $"""
         <svg width="100" height="100">
           <rect width="{rect.Width}" 
                 height="{rect.Height}" 
                 style="fill:rgb(0,255,200)" />
         </svg>
         """, 
    mimeType: "text/html");

After this code has been run, Rectangle objects will be displayed as graphical rectangles rather than lists of property values. (The following examples use the C# Script trailing expression syntax which will usually be configured to use the text/html MIME type in notebooks.)

image

It's also worth pointing out that when the customized type is encountered within a list or as an object property, custom formatters will still be invoked.

Here's an example showing an array:

image

Here's an example showing an anonymous object with a property of type Rectangle:

image

Other overloads of Formatter.Register enable you to handle some additional complexities.

Open generic types

Formatters can be specified by using an open generic type definition as a key. The following will register a formatter for variants of List<T> for all types T and print each element along with its hash code. (Note that it's necessary to cast the object in order to iterate over its items.)

Formatter.Register(
    type: typeof(List<>),
    formatter: (list, writer) =>
    {
        foreach (var obj in (IEnumerable)list)
        {
            writer.WriteLine($"{obj} ({obj.GetHashCode()})");
        }
    }, "text/html");

After running the code above, the following will no longer print just the values in the list.

var list = new List<string> { "one", "two", "three" };
list

The output will now look like this:

one (254814599) two (656421459) three (-1117028319)

TypeFormatterSourceAttribute

There is another approach that can be used to register custom formatters. You can decorate a type with TypeFormatterSourceAttribute. This isn't the most convenient approach if you want to redefine formatter settings directly within a notebook. But if you're writing a .NET Interactive extension, or a library or app that includes custom formatting for certain types, this is the recommended approach. One reason for this is that the attribute-based approach is simpler. Another reason is that attribute-based formatter customizations are not cleared when Formatter.ResetToDefault() is called, while formatters configured using Formatter.Register are cleared. You can think of the attribute-based registration approach as a way to set the default formatting for a type.

There are two approaches to attribute-based formatter registration: one for when your project references Microsoft.DotNet.Interactive.Formatting and another for when it does not.

If you already have a reference to Microsoft.DotNet.Interactive.Formatting, for example because you're writing a .NET Interactive extension, then you can decorate your types that need custom formatting with the TypeFormatterSourceAttribute defined in Microsoft.DotNet.Interactive.Formatting. Here's an example:

[TypeFormatterSource(typeof(MyFormatterSource))]
public class MyTypeWithCustomFormatting
{
}

A formatter source specified by the TypeFormatterSourceAttribute must implement ITypeFormatterSource and must have an empty constructor. It does not need to be a public type. Here's an example implementation:

internal class MyFormatterSource : ITypeFormatterSource
{
    public IEnumerable<ITypeFormatter> CreateTypeFormatters()
    {
        return new ITypeFormatter[]
        {
            new PlainTextFormatter<MyTypeWithCustomFormatting>(context => 
              $"Hello from {nameof(MyFormatterSource)} using MIME type text/plain"),
            new HtmlFormatter<MyTypeWithCustomFormatting>(context => 
              $"Hello from {nameof(MyFormatterSource)} using MIME type text/html")
        };
    }
}

As the example above shows, a formatter source can return multiple formatters. These can be formatters for different MIME types and also for different .NET types.

A formatter source can also specify the preferred MIME types for your type:

[TypeFormatterSource(
  typeof(MyFormatterSource), 
  PreferredMimeTypes = new[] { "text/html", "application/json" })]
public class MyTypeWithCustomFormatting
{
}

Specifying several MIME types results in a call to Formatter.SetPreferredMimeTypesFor, as described under Preferred MIME types. The behavior is equivalent.

If you don't want to take a dependency on Microsoft.DotNet.Interactive.Formatting, there's another to way to provide custom formatting for your types. This can be the simpler approach if you're building a library and you'd like to provide an augmented experience in notebooks but you don't need the other tools available to .NET Interactive extensions, such as magic commands and custom kernels.

With this approach, you declare your own TypeFormatterSourceAttribute and apply it. The shape of the class should match the TypeFormatterSourceAttribute defined in Microsoft.DotNet.Interactive.Formatting. The TypeFormatterSourceAttribute class that you define will be recognized by name. Like the formatter source, it can be declared as internal so that it has no impact on your public API surface.

Here's the complete definition, which you can copy into your project:

[AttributeUsage(AttributeTargets.Class)]
internal class TypeFormatterSourceAttribute : Attribute
{
    public TypeFormatterSourceAttribute(Type formatterSourceType)
    {
        FormatterSourceType = formatterSourceType;
    }

    public Type FormatterSourceType { get; }

    public string[] PreferredMimeTypes { get; set; }
}

As with the attribute class itself, the specified formatter source and the formatters it returns are based on naming conventions rather than types. The following is a complete example that you can use as a template:

internal class MyConventionBasedFormatterSource
{
    public IEnumerable<object> CreateTypeFormatters()
    {
        yield return new MyConventionBasedFormatter { MimeType = "text/html" };
    }
}

internal class MyConventionBasedFormatter
{
    public string MimeType { get; set; }

    public bool Format(object instance, TextWriter writer)
    {
        if (instance is MyTypeWithCustomFormatting myObj)
        {
            writer.Write($"<div>Custom formattering for {obj}</div>");
            return true;
        }
        else
        {
            return false;
        }
    }
}

Resetting formatting configurations

As you experiment with different formatting configurations, you might find you want to reset everything to the defaults that you see when you first start the kernel. You can do this easily:

Formatter.ResetToDefault();

This will reset all of the configurations that might have been changed by using the APIs described above. Note that this might also reset formatting set up by extensions installed using NuGet packages.

How a formatter is chosen

It's possible to have more than one formatter registered that might apply to the same type. For example, formatters can be registered for object, IEnumerable<string>, and IList<string>, any of which might reasonably apply to an instance of List<string>. For these reasons, it can be useful to understand how a formatter is chosen.

The applicable formatter is chosen for an object of type A as follows:

  1. If no MIME type is specified, determine one:

    • Choose the most specific user-registered MIME type preference relevant to A.

    • If no user-registered MIME types are relevant, then use the configured default MIME type.

    This MIME type is looked up by by calling Formatter.GetPreferredMimeTypesFor(typeof(A)), which returns one or more MIME types. If the one specified by Formatter.DefaultMimeType is in the list, it will be preferred.

  2. Next, determine a formatter:

    • Choose the most specific user-registered formatter relevant to A.

    • If no user-registered formatters are relevant, then choose a default formatter.

Here, "most specific" is in terms of the class and interface hierarchy. In the event of an exact tie in ordering or some other conflict, more recent registrations are preferred. Type-instantiations of generic types are preferred to generic formatters when their type definitions are the same.

The default sets of formatters for a MIME type always include a formatter for object.

This set of rules is implemented by the Formatter.GetPreferredFormatterFor method. Here's an example of how you can use it to get a formatter instance and format an object with it:

using System.Drawing;
using System.IO;
using Microsoft.DotNet.Interactive.Formatting;

ITypeFormatter formatter = 
    Formatter.GetPreferredFormatterFor(
        typeof(Rectangle), 
        Formatter.DefaultMimeType);

var rect = new Rectangle { X = 100, Y = 50 };

var writer = new StringWriter();

formatter.Format(rect, writer);

Console.WriteLine(writer.ToString());

Examples

Here are some examples to illustrate how formatter selection works.

  • If you register a formatter for type A then it is used for all objects of type A (until an alternative formatter for type A is later specified).

  • If you register a formatter for System.Object, it is preferred over all other formatters except other user-defined formatters that are more specific.

  • If you register a formatter for any sealed type, it is preferred over all other formatters (unless more formatters for that type are specified).

  • If you register List<> and List<int> formatters, the List<int> formatter is preferred for objects of type List<int>, while the List<> formatter is preferred for other generic instantiations, for example List<string>.

See also