Skip to content

Commit

Permalink
feat: decode cell attachments in raw and markdown cells
Browse files Browse the repository at this point in the history
The testing package got the corresponding fixture and is now made public
(internal/test -> pkg/test) so that it can be used in external packages
providing nb extensions.
  • Loading branch information
bevzzz committed Jan 25, 2024
1 parent 782a14d commit 3d5fc77
Show file tree
Hide file tree
Showing 10 changed files with 208 additions and 20 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
2 changes: 1 addition & 1 deletion extension/adapter/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"testing"

"github.com/bevzzz/nb/extension/adapter"
"github.com/bevzzz/nb/internal/test"
"github.com/bevzzz/nb/pkg/test"
"github.com/bevzzz/nb/render"
"github.com/bevzzz/nb/schema"
)
Expand Down
2 changes: 1 addition & 1 deletion extension/extension_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (

"github.com/bevzzz/nb"
"github.com/bevzzz/nb/extension"
"github.com/bevzzz/nb/internal/test"
"github.com/bevzzz/nb/pkg/test"
"github.com/bevzzz/nb/render"
"github.com/bevzzz/nb/schema"
"github.com/stretchr/testify/require"
Expand Down
80 changes: 80 additions & 0 deletions internal/test/cell.go → pkg/test/cell.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// Package test provides test doubles that implement some of nb interfaces.
// Authors of nb-extension packages are encouraged to use them as they make
// for a uniform test code across different packages.
// See it's example usages in schema/**/*_test.go files.
package test

import (
Expand Down Expand Up @@ -95,3 +99,79 @@ var _ schema.Notebook = (*cells)(nil)
func (n cells) Version() (v schema.Version) { return }

func (n cells) Cells() []schema.Cell { return n }

// WithAttachments creates a cell that has an attachment.
//
// The underlying test implementation for schema.MimeBundle accesses
// its keys in a random order and should always be created with 1 element only
// to keep test outcomes stable and predictable.
//
// Example:
//
// test.WithAttachments(
// test.Markdown("![img](attachment:photo:png)"),
// "photo.png",
// map[string]interface{"image/png": "base64-encoded-image"}
// )
func WithAttachment(c schema.Cell, filename string, mimebundle map[string]interface{}) interface {
schema.Cell
schema.HasAttachments
} {
return &struct {
schema.Cell
schema.HasAttachments
}{
Cell: c,
HasAttachments: &cellAttachment{
filename: filename,
mb: mimebundle,
},
}
}

// cellWithAttachment fakes a single cell attachment.
type cellAttachment struct {
filename string
mb mimebundle
}

var _ schema.HasAttachments = (*cellAttachment)(nil)
var _ schema.Attachments = (*cellAttachment)(nil)

func (c *cellAttachment) Attachments() schema.Attachments {
return c
}

// MimeBundle returns the underlying mime-bundle if the filename matches.
func (c *cellAttachment) MimeBundle(filename string) schema.MimeBundle {
if filename != c.filename {
return nil
}
return c.mb
}

// mimebundle is a mock implementation of schema.MimeBundle, which always
// returns the mime-type and content of its first (random access) element.
// It does not differentiate between "richer" mime-types and should not be
// created with more than one entry to keep the tests stable and reproducible.
type mimebundle map[string]interface{}

var _ schema.MimeBundle = new(mimebundle)

func (mb mimebundle) MimeType() string {
for mt := range mb {
return mt
}
return common.PlainText
}

func (mb mimebundle) Text() []byte {
return mb[mb.MimeType()].([]byte)
}

func (mb mimebundle) PlainText() []byte {
if mb.MimeType() == common.PlainText {
return mb.Text()
}
return nil
}
2 changes: 1 addition & 1 deletion render/html/html_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"

"github.com/bevzzz/nb/internal/test"
"github.com/bevzzz/nb/pkg/test"
"github.com/bevzzz/nb/render"
"github.com/bevzzz/nb/render/html"
"github.com/bevzzz/nb/schema"
Expand Down
2 changes: 1 addition & 1 deletion render/html/wrapper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (

"github.com/stretchr/testify/require"

"github.com/bevzzz/nb/internal/test"
"github.com/bevzzz/nb/pkg/test"
"github.com/bevzzz/nb/render/html"
"github.com/bevzzz/nb/schema"
"github.com/bevzzz/nb/schema/common"
Expand Down
2 changes: 1 addition & 1 deletion render/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"strings"
"testing"

"github.com/bevzzz/nb/internal/test"
"github.com/bevzzz/nb/pkg/test"
"github.com/bevzzz/nb/render"
"github.com/bevzzz/nb/schema"
"github.com/bevzzz/nb/schema/common"
Expand Down
2 changes: 1 addition & 1 deletion schema/common/notebook.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
type Notebook struct {
VersionMajor int `json:"nbformat"`
VersionMinor int `json:"nbformat_minor"`
Metadata json.RawMessage `json:"metadata"`
Metadata json.RawMessage `json:"metadata"` // TODO: omitempty
Cells []json.RawMessage `json:"cells"`
}

Expand Down
13 changes: 13 additions & 0 deletions schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ type Cell interface {
Text() []byte
}

type HasAttachments interface {
// Attachments are only defined for v4.0 and above for markdown and raw cells
// and may be omitted in the JSON. Cells without attachments should return nil.
Attachments() Attachments
}

// CellType reports the intended cell type to the components that work
// with notebook cells through the Cell interface.
//
Expand Down Expand Up @@ -123,3 +129,10 @@ type MimeBundle interface {
// A renderer may want to fallback to this option if it is not able to render the richer mime-type.
PlainText() []byte
}

// Attachments are data for inline images stored as a mime-bundle keyed by filename.
type Attachments interface {
// MimeBundle returns a mime-bundle associated with the filename.
// If no data is present for the file, implementations should return nil.
MimeBundle(filename string) MimeBundle
}
25 changes: 25 additions & 0 deletions schema/v4/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,12 @@ func (nm *NotebookMetadata) Language() string {

// Markdown defines the schema for a "markdown" cell.
type Markdown struct {
Att Attachments `json:"attachments,omitempty"`
Source common.MultilineString `json:"source"`
}

var _ schema.Cell = (*Markdown)(nil)
var _ schema.HasAttachments = (*Markdown)(nil)

func (md *Markdown) Type() schema.CellType {
return schema.Markdown
Expand All @@ -77,13 +79,19 @@ func (md *Markdown) Text() []byte {
return md.Source.Text()
}

func (md *Markdown) Attachments() schema.Attachments {
return md.Att
}

// Raw defines the schema for a "raw" cell.
type Raw struct {
Att Attachments `json:"attachments,omitempty"`
Source common.MultilineString `json:"source"`
Metadata RawCellMetadata `json:"metadata"`
}

var _ schema.Cell = (*Raw)(nil)
var _ schema.HasAttachments = (*Raw)(nil)

func (raw *Raw) Type() schema.CellType {
return schema.Raw
Expand All @@ -97,6 +105,23 @@ func (raw *Raw) Text() []byte {
return raw.Source.Text()
}

func (raw *Raw) Attachments() schema.Attachments {
return raw.Att
}

// Attachments store mime-bundles keyed by filename.
type Attachments map[string]MimeBundle

var _ schema.Attachments = new(Attachments)

func (att Attachments) MimeBundle(filename string) schema.MimeBundle {
mb, ok := att[filename]
if !ok {
return nil
}
return mb
}

// RawCellMetadata may specify a target conversion format.
type RawCellMetadata struct {
Format *string `json:"format"`
Expand Down

0 comments on commit 3d5fc77

Please sign in to comment.