diff --git a/decode/decode_test.go b/decode/decode_test.go index 6d5280a..d597988 100644 --- a/decode/decode_test.go +++ b/decode/decode_test.go @@ -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 { @@ -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"), }, }, } { @@ -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) }) } }) @@ -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", @@ -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", @@ -108,11 +130,11 @@ func TestDecodeBytes(t *testing.T) { {"cell_type": "raw", "metadata": {"format": "text/html"}, "source": ["

Hi, mom!

"]} ] }`, - want: Cell{ + want: WithAttachments{Cell: Cell{ Type: schema.Raw, MimeType: "text/html", Text: []byte("

Hi, mom!

"), - }, + }}, }, { name: "v4.4: metadata.raw_mimetype has specific mime-type", @@ -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"), }, }, } { @@ -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) }) } }) @@ -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() diff --git a/extension/adapter/adapter_test.go b/extension/adapter/adapter_test.go index e3ddac1..e1bd30e 100644 --- a/extension/adapter/adapter_test.go +++ b/extension/adapter/adapter_test.go @@ -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" ) diff --git a/extension/extension_test.go b/extension/extension_test.go index c156ee3..8a19957 100644 --- a/extension/extension_test.go +++ b/extension/extension_test.go @@ -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" diff --git a/internal/test/cell.go b/internal/test/cell.go deleted file mode 100644 index 250832f..0000000 --- a/internal/test/cell.go +++ /dev/null @@ -1,97 +0,0 @@ -package test - -import ( - "github.com/bevzzz/nb/schema" - "github.com/bevzzz/nb/schema/common" -) - -// Markdown creates schema.Markdown cell with source s. -func Markdown(s string) schema.Cell { - return &Cell{CellType: schema.Markdown, Mime: common.MarkdownText, Source: []byte(s)} -} - -// Raw creates schema.Raw cell with source s and reported mime-type mt. -func Raw(s, mt string) schema.Cell { - return &Cell{CellType: schema.Raw, Mime: mt, Source: []byte(s)} -} - -// DisplayData creates schema.DisplayData cell with source s and reported mime-type mt. -func DisplayData(s, mt string) schema.Cell { - return &Cell{CellType: schema.DisplayData, Mime: mt, Source: []byte(s)} -} - -// ExecuteResult creates schema.ExecuteResult cell with source s, reported mime-type mt and execution count n. -func ExecuteResult(s, mt string, n int) schema.Cell { - return &ExecuteResultOutput{ - Cell: Cell{CellType: schema.ExecuteResult, Mime: mt, Source: []byte(s)}, - TimesExecuted: n, - } -} - -// ErrorOutput creates schema.Error cell with source s and mime-type common.Stderr. -func ErrorOutput(s string) schema.Cell { - return &Cell{CellType: schema.Error, Mime: common.Stderr, Source: []byte(s)} -} - -// Stdout creates schema.Stream cell with source s and mime-type common.Stdout. -func Stdout(s string) schema.Cell { - return &Cell{CellType: schema.Stream, Mime: common.Stdout, Source: []byte(s)} -} - -// Stderr creates schema.Stream cell with source s and mime-type common.Stderr. -func Stderr(s string) schema.Cell { - return &Cell{CellType: schema.Stream, Mime: common.Stderr, Source: []byte(s)} -} - -// Cell is a test fixture to mock schema.Cell. -type Cell struct { - CellType schema.CellType - Mime string // mime-type (avoid name-clash with the interface method) - Source []byte -} - -var _ schema.Cell = (*Cell)(nil) - -func (c *Cell) Type() schema.CellType { return c.CellType } -func (c *Cell) MimeType() string { return c.Mime } -func (c *Cell) Text() []byte { return c.Source } - -// CodeCell is a test fixture to mock schema.CodeCell. -// Use cases which only require schema.Cell, should create &test.Cell{CT: schema.Code} instead. -type CodeCell struct { - Cell - Lang string - TimesExecuted int - Out []schema.Cell -} - -var _ schema.CodeCell = (*CodeCell)(nil) - -func (code *CodeCell) Language() string { return code.Lang } -func (code *CodeCell) ExecutionCount() int { return code.TimesExecuted } -func (code *CodeCell) Outputs() []schema.Cell { return code.Out } - -// ExecuteResultOutput is a test fixture to mock cell outputs with ExecuteResult type. -type ExecuteResultOutput struct { - Cell - TimesExecuted int -} - -var _ schema.Cell = (*ExecuteResultOutput)(nil) -var _ interface{ ExecutionCount() int } = (*ExecuteResultOutput)(nil) - -func (ex *ExecuteResultOutput) ExecutionCount() int { return ex.TimesExecuted } - -// Notebook wraps a slice of cells into a simple schema.Notebook implementation. -func Notebook(cs ...schema.Cell) schema.Notebook { - return cells(cs) -} - -// cells implements schema.Notebook for a slice of cells. -type cells []schema.Cell - -var _ schema.Notebook = (*cells)(nil) - -func (n cells) Version() (v schema.Version) { return } - -func (n cells) Cells() []schema.Cell { return n } diff --git a/render/html/html_test.go b/render/html/html_test.go index f328f92..86ed678 100644 --- a/render/html/html_test.go +++ b/render/html/html_test.go @@ -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" diff --git a/render/html/wrapper_test.go b/render/html/wrapper_test.go index dffa187..db95d7d 100644 --- a/render/html/wrapper_test.go +++ b/render/html/wrapper_test.go @@ -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" diff --git a/render/render_test.go b/render/render_test.go index f3a2e85..a559a50 100644 --- a/render/render_test.go +++ b/render/render_test.go @@ -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" diff --git a/schema/common/notebook.go b/schema/common/notebook.go index 3550967..8246fe7 100644 --- a/schema/common/notebook.go +++ b/schema/common/notebook.go @@ -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"` } diff --git a/schema/schema.go b/schema/schema.go index 15a38e9..e6df60d 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -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. // @@ -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 +} diff --git a/schema/v4/schema.go b/schema/v4/schema.go index de1885b..940f122 100644 --- a/schema/v4/schema.go +++ b/schema/v4/schema.go @@ -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 @@ -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 @@ -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"`