Skip to content

Commit

Permalink
Built-in extensions (#4)
Browse files Browse the repository at this point in the history
* Preference API allows renderers to target specific cell/mime- types
* Extension API + convenient adapters for popular packages:
  * markdown: goldmark and blackfriday
  * stream outputs: ansihtml
* internal/test -> pkg/test: handy test utils now available for usage in external packages 
  * NoWrapper render option
* decode cell attachments for v4 notebooks.
  • Loading branch information
bevzzz authored Jan 26, 2024
1 parent b2b0928 commit d7249bd
Show file tree
Hide file tree
Showing 17 changed files with 592 additions and 27 deletions.
98 changes: 84 additions & 14 deletions decode/decode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ type Cell struct {
Text []byte
}

type WithAttachments struct {
Cell
Filename string
MimeType string
Data []byte
}

func (w WithAttachments) HasAttachments() bool {
return w.Filename != ""
}

func TestDecodeBytes(t *testing.T) {
t.Run("notebook", func(t *testing.T) {
for _, tt := range []struct {
Expand Down Expand Up @@ -54,19 +65,30 @@ func TestDecodeBytes(t *testing.T) {
for _, tt := range []struct {
name string
json string
want Cell
want WithAttachments
}{
{
name: "v4.4",
json: `{
"nbformat": 4, "nbformat_minor": 4, "metadata": {}, "cells": [
{"cell_type": "markdown", "metadata": {}, "source": ["Join", " ", "me"]}
{"cell_type": "markdown", "metadata": {}, "source": [
"Look", " at ", "me: ![alt](attachment:photo.png)"
], "attachments": {
"photo.png": {
"image/png": "base64-encoded-image-data"
}
}}
]
}`,
want: Cell{
Type: schema.Markdown,
MimeType: common.MarkdownText,
Text: []byte("Join me"),
want: WithAttachments{
Cell: Cell{
Type: schema.Markdown,
MimeType: common.MarkdownText,
Text: []byte("Look at me: ![alt](attachment:photo.png)"),
},
Filename: "photo.png",
MimeType: "image/png",
Data: []byte("base64-encoded-image-data"),
},
},
} {
Expand All @@ -77,7 +99,7 @@ func TestDecodeBytes(t *testing.T) {
got := nb.Cells()
require.Len(t, got, 1, "expected 1 cell")

checkCell(t, got[0], tt.want)
checkCellWithAttachments(t, got[0], tt.want)
})
}
})
Expand All @@ -86,7 +108,7 @@ func TestDecodeBytes(t *testing.T) {
for _, tt := range []struct {
name string
json string
want Cell
want WithAttachments
}{
{
name: "v4.4: no explicit mime-type",
Expand All @@ -95,11 +117,11 @@ func TestDecodeBytes(t *testing.T) {
{"cell_type": "raw", "source": ["Plain as the nose on your face"]}
]
}`,
want: Cell{
want: WithAttachments{Cell: Cell{
Type: schema.Raw,
MimeType: common.PlainText,
Text: []byte("Plain as the nose on your face"),
},
}},
},
{
name: "v4.4: metadata.format has specific mime-type",
Expand All @@ -108,11 +130,11 @@ func TestDecodeBytes(t *testing.T) {
{"cell_type": "raw", "metadata": {"format": "text/html"}, "source": ["<p>Hi, mom!</p>"]}
]
}`,
want: Cell{
want: WithAttachments{Cell: Cell{
Type: schema.Raw,
MimeType: "text/html",
Text: []byte("<p>Hi, mom!</p>"),
},
}},
},
{
name: "v4.4: metadata.raw_mimetype has specific mime-type",
Expand All @@ -121,10 +143,35 @@ func TestDecodeBytes(t *testing.T) {
{"cell_type": "raw", "metadata": {"raw_mimetype": "application/x-latex"}, "source": ["$$"]}
]
}`,
want: Cell{
want: WithAttachments{Cell: Cell{
Type: schema.Raw,
MimeType: "application/x-latex",
Text: []byte("$$"),
}},
},
{
name: "v4.4: with attachments",
json: `{
"nbformat": 4, "nbformat_minor": 4, "metadata": {}, "cells": [
{
"cell_type": "raw", "metadata": {},
"source": ["![alt](attachment:photo.png)"], "attachments": {
"photo.png": {
"image/png": "base64-encoded-image-data"
}
}
}
]
}`,
want: WithAttachments{
Cell: Cell{
Type: schema.Raw,
MimeType: common.PlainText,
Text: []byte("![alt](attachment:photo.png)"),
},
Filename: "photo.png",
MimeType: "image/png",
Data: []byte("base64-encoded-image-data"),
},
},
} {
Expand All @@ -135,7 +182,7 @@ func TestDecodeBytes(t *testing.T) {
got := nb.Cells()
require.Len(t, got, 1, "expected 1 cell")

checkCell(t, got[0], tt.want)
checkCellWithAttachments(t, got[0], tt.want)
})
}
})
Expand Down Expand Up @@ -398,6 +445,29 @@ func checkCell(tb testing.TB, got schema.Cell, want Cell) {
}
}

// checkCellWithAttachments compares the cell's type, content, and attachments to expected.
func checkCellWithAttachments(tb testing.TB, got schema.Cell, want WithAttachments) {
tb.Helper()
checkCell(tb, got, want.Cell)
if !want.HasAttachments() {
return
}

cell, ok := got.(schema.HasAttachments)
if !ok {
tb.Fatal("cell has no attachments (does not implement schema.HasAttachments)")
}

var mb schema.MimeBundle
att := cell.Attachments()
if mb = att.MimeBundle(want.Filename); mb == nil {
tb.Fatalf("no data for %s, want %q", want.Filename, want.Data)
}

require.Equal(tb, want.MimeType, mb.MimeType(), "reported mime-type")
require.Equal(tb, want.Data, mb.Text(), "attachment data")
}

// toCodeCell fails the test if the cell does not implement schema.CodeCell.
func toCodeCell(tb testing.TB, cell schema.Cell) schema.CodeCell {
tb.Helper()
Expand Down
62 changes: 62 additions & 0 deletions extension/adapter/adapter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package adapter_test

import (
"io"
"strings"
"testing"

"github.com/bevzzz/nb/extension/adapter"
"github.com/bevzzz/nb/pkg/test"
"github.com/bevzzz/nb/render"
"github.com/bevzzz/nb/schema"
)

func TestAdapter(t *testing.T) {
for _, tt := range []struct {
name string
render render.RenderCellFunc
cell schema.Cell
want string
}{
{
name: "Goldmark",
render: adapter.Goldmark(func(b []byte, w io.Writer) error {
w.Write(b)
return nil
}),
cell: test.Markdown("Hi, mom!"),
want: "Hi, mom!",
},
{
name: "Blackfriday",
render: adapter.Blackfriday(func(b []byte) []byte { return b }),
cell: test.Markdown("Hi, mom!"),
want: "Hi, mom!",
},
{
name: "AnsiHtml",
render: adapter.AnsiHtml(func(b []byte) []byte { return b }),
cell: test.Stdout("Hi, mom!"),
want: "Hi, mom!",
},
{
name: "AnsiHtml",
render: adapter.AnsiHtml(func(b []byte) []byte { return b }),
cell: test.Stderr("Hi, mom!"),
want: "Hi, mom!",
},
} {
t.Run(tt.name, func(t *testing.T) {
// Arrange
var sb strings.Builder

// Act
tt.render(&sb, tt.cell)

// Assert
if got := sb.String(); got != tt.want {
t.Errorf("wrong content: want %q, got %q", tt.want, got)
}
})
}
}
32 changes: 32 additions & 0 deletions extension/adapter/ansi.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package adapter

import (
"io"

"github.com/bevzzz/nb/render"
"github.com/bevzzz/nb/schema"
)

// AnsiHtml wraps [ansihtml]-style function in RenderCellFunc.
//
// Usage:
//
// extension.NewStream(
// adapter.AnsiHtml(ansihtml.ConvertToHTML)
// )
//
// To force ansihtml to use classes instead of inline styles, pass an anonymous function intead:
//
// extension.NewStream(
// adapter.AnsiHtml(func([]byte) []byte) {
// ansihtml.ConvertToHTMLWithClasses(b, "class-", false)
// })
// )
//
// [ansihtml]: https://github.com/robert-nix/ansihtml
func AnsiHtml(convert func([]byte) []byte) render.RenderCellFunc {
return func(w io.Writer, cell schema.Cell) (err error) {
_, err = w.Write(convert(cell.Text()))
return
}
}
10 changes: 10 additions & 0 deletions extension/adapter/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Package adapter provides convenient adapters for other popular packages
// making it simple to use those as nb extensions.
//
// - Markdown: [goldmark] and [blackfriday]
// - ANSI to HTML conversion: [ansihtml]
//
// [goldmark]: https://github.com/yuin/goldmark
// [blackfriday]: https://github.com/russross/blackfriday
// [ansihtml]: https://github.com/robert-nix/ansihtml
package adapter
45 changes: 45 additions & 0 deletions extension/adapter/md.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package adapter

import (
"io"

"github.com/bevzzz/nb/render"
"github.com/bevzzz/nb/schema"
)

// Blackfriday wraps [blackfriday]-style function in RenderCellFunc.
//
// Usage:
//
// extension.NewMarkdown(
// adapter.Blackfriday(blackfriday.MarkdownCommon)
// )
//
// [blackfriday]: https://github.com/russross/blackfriday
func Blackfriday(convert func([]byte) []byte) render.RenderCellFunc {
return func(w io.Writer, cell schema.Cell) (err error) {
_, err = w.Write(convert(cell.Text()))
return
}
}

// Goldmark wraps [goldmark]-style function in RenderCellFunc.
//
// Usage:
//
// extension.NewMarkdown(
// adapter.Goldmark(func(b []byte, w io.Writer) error {
// return goldmark.Convert(b, w, parseOptions...)
// })
// )
//
// Notice, how Goldmark is a bit more verbose compared to Blackfriday:
// this is because goldmark.Convert accepts variadic parser.ParseOptions, which
// is a dependency the client should capture in the closure and pass manually.
//
// [goldmark]: https://github.com/yuin/goldmark
func Goldmark(write func([]byte, io.Writer) error) render.RenderCellFunc {
return func(w io.Writer, cell schema.Cell) error {
return write(cell.Text(), w)
}
}
Loading

0 comments on commit d7249bd

Please sign in to comment.