diff --git a/bokehjs/src/compiler/linker.ts b/bokehjs/src/compiler/linker.ts index f102d23cd4e..ca58f25b41d 100644 --- a/bokehjs/src/compiler/linker.ts +++ b/bokehjs/src/compiler/linker.ts @@ -562,7 +562,11 @@ export class Linker { const path = join(dir, subpath) for (const [key, val] of Object.entries(export_map)) { if (join(dir, key) == path) { - return join(dir, val) + if (typeof val === "string") { + return join(dir, val) + } else { + return join(dir, val.default) + } } } return null diff --git a/bokehjs/src/lib/core/csv/cast.ts b/bokehjs/src/lib/core/csv/cast.ts new file mode 100644 index 00000000000..0bdda48f3c1 --- /dev/null +++ b/bokehjs/src/lib/core/csv/cast.ts @@ -0,0 +1,37 @@ +/*! + * The code in this file is derived from node-csv + * + * + * Original code Copyright (c) 2010 Adaltas + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import {to_string} from "core/util/pretty" + +// The default function for casting data to strings for CSV fields +export function default_cast(field: unknown): string { + if (field instanceof Date) { + return field.toISOString() + } else if (typeof field === "object") { + // includes null => 'null' + return to_string(field) + } else { + return String(field) + } +} diff --git a/bokehjs/src/lib/core/csv/csv_error.ts b/bokehjs/src/lib/core/csv/csv_error.ts new file mode 100644 index 00000000000..94df573e465 --- /dev/null +++ b/bokehjs/src/lib/core/csv/csv_error.ts @@ -0,0 +1,46 @@ +/*! + * The code in this file is derived from node-csv + * + * + * Original code Copyright (c) 2010 Adaltas + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +export class CsvError extends Error { + [key: string]: any + code: string + + constructor(code: string, message: string | string[], ...contexts: Record[]) { + if (Array.isArray(message)) { message = message.join(" ") } + super(message) + if ("captureStackTrace" in Error && typeof Error.captureStackTrace === "function") { + Error.captureStackTrace(this, CsvError) + } + this.code = code + for (const context of contexts) { + // eslint-disable-next-line + for (const key in context) { + const value = context[key] + this[key] = value == null + ? value + : JSON.parse(JSON.stringify(value)) + } + } + } +} diff --git a/bokehjs/src/lib/core/csv/index.ts b/bokehjs/src/lib/core/csv/index.ts new file mode 100644 index 00000000000..21b3c705cea --- /dev/null +++ b/bokehjs/src/lib/core/csv/index.ts @@ -0,0 +1,2 @@ +export type {Options} from "./types" +export {stringify, line_generator} from "./stringifier" diff --git a/bokehjs/src/lib/core/csv/normalize_options.ts b/bokehjs/src/lib/core/csv/normalize_options.ts new file mode 100644 index 00000000000..83bb7e95e32 --- /dev/null +++ b/bokehjs/src/lib/core/csv/normalize_options.ts @@ -0,0 +1,43 @@ +/*! + * The code in this file is derived from node-csv + * + * + * Original code Copyright (c) 2010 Adaltas + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import type {NormalizedOptions, Options} from "./types" +import {CsvError} from "./csv_error" +import {default_cast} from "./cast" + +export function normalize_options(opts: Options): NormalizedOptions { + const options: Options = {...opts} + + // Normalize option `cast` + if (options.cast === undefined) { + options.cast = default_cast + } else if (typeof options.cast !== "function") { + throw new CsvError("CSV_OPTION_CAST_INVALID_TYPE", [ + "option `cast` must be a function,", + `got ${JSON.stringify(options.cast)}`, + ]) + } + + return options as NormalizedOptions +} diff --git a/bokehjs/src/lib/core/csv/stringifier.ts b/bokehjs/src/lib/core/csv/stringifier.ts new file mode 100644 index 00000000000..27f33e4de98 --- /dev/null +++ b/bokehjs/src/lib/core/csv/stringifier.ts @@ -0,0 +1,139 @@ +/*! + * The code in this file is derived from node-csv + * + * + * Original code Copyright (c) 2010 Adaltas + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import type { + Options, + NormalizedOptions, + Record as CSVRecord, + CastContext, +} from "./types" +import {normalize_options} from "./normalize_options" + +export class Stringifier { + options: NormalizedOptions + + constructor(options: Options) { + this.options = normalize_options(options) + } + + *line_generator(records: Iterable): Generator { + let first_row + let row_index = 0 + for (const record of records) { + if (row_index === 0) { + first_row = record + } + const context = {first_row, row_index, column_index: 0} + let line = this.stringify_record(record, context) + line += "\n" + yield line + row_index++ + } + } + + // Create the full CSV string that can be written to a .csv file + stringify(records: Iterable): string { + let result = "" + for (const line of this.line_generator(records)) { + result += line + } + return result + } + + // Create string for a single record (row) of CSV data. Do not include the + // record delimiter. Example usage: + // + // stringify_record(['foo', 1]) + // > 'foo,1' + stringify_record(record: unknown[], context: CastContext): string { + const fields = [] + for (let column_index = 0; column_index < record.length; column_index++) { + const field = record[column_index] + context.column_index = column_index + const field_as_string = this.cast(field, context) + if (typeof field_as_string !== "string") { + throw new Error( + `Invalid Casting Value: string cast function must return a string, got ${JSON.stringify( + field, + )}`, + ) + } + fields.push(this.quote(this.escape(field_as_string))) + } + return fields.join(",") + } + + // Convert the CSV field to a string + private cast(field: unknown, context: CastContext): string { + return this.options.cast(field, context) + } + + // Any quotes inside a CSV field must be escaped with a quote. Example usage: + // + // escape('{"a":1}') + // > '{""a"":1}' + // + // Important! Because this function looks for quotes, it must be called before + // the quote function. + private escape(field: string): string { + return field.replace(/"/g, '""') + } + + // If the CSV field contains any of a certain set characters, then the entire + // field must by quoted. Example usage: + // + // quote('{""a"":1}') + // > '"{""a"":1}"' + // + // quote('New York, NY') + // > '"New York, NY"' + private quote(field: string): string { + if (/[",\n]/.test(field)) { + return `"${field}"` + } + return field + } +} + +/* Example usage: + + stringify( + [ + ["x", "y"], + [5, 25], + ] + ) + > 'x,y\n5,25\n' +*/ +export function stringify(records: Iterable, options: Options = {}) { + const stringifier = new Stringifier(options) + return stringifier.stringify(records) +} + +// This generator is like stringify but it yields one line at a time instead of +// all lines at once +export function* line_generator(records: Iterable, options: Options = {}) { + const stringifier = new Stringifier(options) + yield* stringifier.line_generator(records) +} diff --git a/bokehjs/src/lib/core/csv/types.ts b/bokehjs/src/lib/core/csv/types.ts new file mode 100644 index 00000000000..6e57726a034 --- /dev/null +++ b/bokehjs/src/lib/core/csv/types.ts @@ -0,0 +1,43 @@ +/*! + * The code in this file is derived from node-csv + * + * + * Original code Copyright (c) 2010 Adaltas + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// In CSV, a record means a row of data +export type Record = any[] + +export type CastContext = { + first_row?: unknown[] + column_index?: number + row_index?: number +} + +// The cast function takes a field from a record and turns it into a string. The +// column name, record count, and column index and passed to the cast function. +export type Cast = (field: unknown, context: CastContext) => string + +export interface Options { + cast?: Cast +} + +export type NormalizedOptions = Required diff --git a/bokehjs/src/lib/core/util/ndarray.ts b/bokehjs/src/lib/core/util/ndarray.ts index ffafb863b4a..2696fd9e570 100644 --- a/bokehjs/src/lib/core/util/ndarray.ts +++ b/bokehjs/src/lib/core/util/ndarray.ts @@ -416,3 +416,55 @@ export function ndarray(init: number | ArrayBufferLike | ArrayLike, {dt case "object": return new ObjectNDArray(init, shape) } } + +/** + * Given an ndarray with shape [p, r, q], the value returned + * at index i (where 0 <= i < p) will be a new ndarray with shape [r, q]. + * + * Example usage: + * const two_colors = ndarray([155, 200, 180, 64, 184, 19], {dtype: 'uint8', shape: [2, 3]}) + * atFirstAxis(two_colors, 1) + * > Uint8NDArray(3) [64, 184, 19, dtype: 'uint8', shape: [3], dimension: 1, ... + */ +export function atFirstAxis( + array: T, + index_along_first_axis: number, +): T | ReturnType { + const {shape} = array + + if (shape.length === 0) { + throw new Error("Cannot index 0-dimension array.") + } + + if (index_along_first_axis < 0 || index_along_first_axis >= shape[0]) { + throw new Error("Index out of bounds.") + } + + /* The return type here is inconsistent with the return type of higher + dimension arrays but it is arguably more intuitive. + + Actual (more intuitive, less consistent): + + const nd = ndarray([100, 101, 102], {dtype: 'uint8', shape: [3]} + atFirstIndex(nd, 0) + > 100 + + Versus (more consistent, less intuitive): + + atFirstIndex(nd, 0) + > Uint8NDArray(1) [100, dtype: 'uint8', shape: [], dimension: 0, ... + */ + if (shape.length === 1) { + return array.get(index_along_first_axis) as ReturnType + } + + const subshape = shape.slice(1) + const n_per_index = subshape.reduce((product, curr) => product * curr, 1) + const flat_index = index_along_first_axis * n_per_index + + const ctor = array.constructor as any // XXX: how can we make TypeScript happy here? + return new ctor( + array.slice(flat_index, flat_index + n_per_index), + subshape, + ) as T +} diff --git a/bokehjs/src/lib/models/sources/columnar_data_source.ts b/bokehjs/src/lib/models/sources/columnar_data_source.ts index b59278cba7b..dbd4acaac5a 100644 --- a/bokehjs/src/lib/models/sources/columnar_data_source.ts +++ b/bokehjs/src/lib/models/sources/columnar_data_source.ts @@ -3,11 +3,13 @@ import {logger} from "core/logging" import type * as p from "core/properties" import {SelectionManager} from "core/selection_manager" import {Signal, Signal0} from "core/signaling" +import {line_generator} from "core/csv/index" +import type {Options as CSVOptions} from "core/csv/index" import type {Arrayable, ArrayableNew, Data, Dict} from "core/types" import type {PatchSet} from "core/patching" import {assert} from "core/util/assert" import {uniq} from "core/util/array" -import {is_NDArray} from "core/util/ndarray" +import {is_NDArray, atFirstAxis} from "core/util/ndarray" import {keys, values, entries, dict, clone} from "core/util/object" import {isBoolean, isNumber, isString, isArray} from "core/util/types" import type {GlyphRenderer} from "../renderers/glyph_renderer" @@ -138,8 +140,12 @@ export abstract class ColumnarDataSource extends DataSource { get_row(index: Index): {[key: string]: unknown} { const i = isNumber(index) ? index : index.index const result: {[key: string]: unknown} = {} - for (const [column, array] of entries(this.data)) { - result[column] = array[i] + for (const [column_name, array] of entries(this.data)) { + if (is_NDArray(array)) { + result[column_name] = atFirstAxis(array, i) + } else { + result[column_name] = array[i] + } } return result } @@ -189,6 +195,33 @@ export abstract class ColumnarDataSource extends DataSource { this.stream_to(this.properties.data, new_data, rollover, {sync}) } + // Returns a generator that iterates through the data as rows rather than + // columns. The first row is the header row. It contains the column names. So + // if your data looks like: {"x": [5, 10], "y", [25, 100]}, the generator + // returns: + // 1. ["x", "y"] + // 2. [5, 25] + // 3. [10, 100] + *row_generator(): Generator { + const header_row = keys(this.data) + if (header_row.length === 0) { + return + } + yield header_row + const num_rows = this.length + for (let r = 0; r < num_rows; r++) { + yield Object.values(this.get_row(r)) + } + } + + *csv_generator(options: CSVOptions = {}): Generator { + yield* line_generator(this.row_generator(), options) + } + + to_csv(options: CSVOptions = {}): string { + return [...this.csv_generator(options)].join("") + } + patch(patches: PatchSet, {sync}: {sync?: boolean} = {}): void { this.patch_to(this.properties.data, patches, {sync}) } diff --git a/bokehjs/test/unit/core/csv/option_cast.ts b/bokehjs/test/unit/core/csv/option_cast.ts new file mode 100644 index 00000000000..33c10c0fca2 --- /dev/null +++ b/bokehjs/test/unit/core/csv/option_cast.ts @@ -0,0 +1,143 @@ +/*! + * The code in this file is derived from node-csv + * + * + * Original code Copyright (c) 2010 Adaltas + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import {expect} from "assertions" + +import {stringify} from "@bokehjs/core/csv" + +describe("core/csv stringify", () => { + + describe("Option `cast`", () => { + + it("should use default cast function when option not provided", () => { + const record = [ + new Date(0), {a: 1}, "foo", + ] + const data = stringify( + [ + record, + ], + ) + expect(data).to.be.equal( + "1970-01-01T00:00:00.000Z,{a: 1},foo\n", + ) + }) + + it("should use provided cast function", () => { + const record = [ + new Date(), {a: 1}, "foo", + ] + const data = stringify( + [ + record, + ], + { + cast(field) { + if (field instanceof Date) { + return "date" + } else if (typeof field === "object") { + return "object" + } else { + return "string" + } + }, + }, + ) + expect(data).to.be.equal("date,object,string\n") + }) + + it("should catch error thrown in cast function", () => { + const fn = () => { + stringify([ + [ + true, + ], + ], + { + cast() { + throw new Error("Catchme") + }, + }, + ) + } + expect(fn).to.throw(Error, "Catchme") + }) + + it("should return a string", () => { + const fn = () => stringify( + [ + [ + true, + ], + ], + // @ts-ignore + {cast: (value) => (value ? 1 : 0)}, + ) + expect(fn).to.throw(Error, "Invalid Casting Value: string cast function must return a string, got true") + }) + + describe("context", () => { + it("should expose the expected properties", () => { + stringify( + [["a"]], + { + cast: (_, context) => { + expect( + Object.keys(context).sort(), + ).to.be.equal( + ["column_index", "first_row", "row_index"], + ) + return "a" + }, + }, + ) + }) + + it("should provide first row, and column and row index", function() { + stringify( + [ + ["P", "Q"], + [true, false], + ], + { + cast: (value, context) => { + if (value === true) { + expect(context.column_index).to.be.equal(0) + expect(context.first_row).to.be.equal(["P", "Q"]) + expect(context.row_index).to.be.equal(1) + return "yes" + } else if (value === false) { + expect(context.column_index).to.be.equal(1) + expect(context.first_row).to.be.equal(["P", "Q"]) + expect(context.row_index).to.be.equal(1) + return "no" + } + return "" + }, + }, + ) + }) + }) + }) +}) diff --git a/bokehjs/test/unit/core/csv/types.ts b/bokehjs/test/unit/core/csv/types.ts new file mode 100644 index 00000000000..c030f3366b2 --- /dev/null +++ b/bokehjs/test/unit/core/csv/types.ts @@ -0,0 +1,93 @@ +/*! + * The code in this file is derived from node-csv + * + * + * Original code Copyright (c) 2010 Adaltas + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import {expect} from "assertions" + +import {stringify} from "@bokehjs/core/csv" + +describe("core/csv stringify", () => { + + it("should not try to tell strings and numbers apart", () => { + const a = stringify([ + ["1", "2"], + ["3", "4"], + ]) + const b = stringify([ + [1, 2], + [3, 4], + ]) + expect(a).to.be.equal(b) + }) + + it("should not choke on dots", () => { + const data = stringify([["a value", ".", "value.with.dot"]]) + expect(data).to.be.equal("a value,.,value.with.dot\n") + }) + + it("should properly quote and escape field with quotes", () => { + const data = stringify([["a \"value\""]]) + expect(data).to.be.equal("\"a \"\"value\"\"\"\n") + }) + + it("should enclose field containing a comma with double quotes", () => { + const data = stringify([["a,b"]]) + expect(data).to.be.equal("\"a,b\"\n") + }) + + it("should enclose field containing line break with double quotes", () => { + const data = stringify([["a\nb"]]) + expect(data).to.be.equal("\"a\nb\"\n") + }) + + it("should map undefined to 'undefined'", () => { + const data = stringify([[undefined, undefined]]) + expect(data).to.be.equal("undefined,undefined\n") + }) + + it("should map null to 'null'", () => { + const data = stringify([[null, null]]) + expect(data).to.be.equal("null,null\n") + }) + + it("should map empty string to empty string", () => { + const data = stringify([["", ""]]) + expect(data).to.be.equal(",\n") + }) + + it("should map date to ISO string", () => { + const datetime = new Date() + const data = stringify([[datetime]]) + expect(data).to.be.equal(`${datetime.toISOString() }\n`) + }) + + it("should map true boolean value to 'true'", () => { + const data = stringify([[true]]) + expect(data).to.be.equal("true\n") + }) + + it("should map object to its core/util/pretty representation", () => { + const data = stringify([[{a: 1}]]) + expect(data).to.be.equal("{a: 1}\n") + }) +}) diff --git a/bokehjs/test/unit/core/util/ndarray.ts b/bokehjs/test/unit/core/util/ndarray.ts index 515b1f273f1..4f3244f82d6 100644 --- a/bokehjs/test/unit/core/util/ndarray.ts +++ b/bokehjs/test/unit/core/util/ndarray.ts @@ -7,6 +7,7 @@ import { Uint32NDArray, Int32NDArray, Float32NDArray, Float64NDArray, ObjectNDArray, + atFirstAxis, } from "@bokehjs/core/util/ndarray" import {Cloner} from "@bokehjs/core/util/cloneable" @@ -592,4 +593,39 @@ describe("core/util/ndarray module", () => { expect(nd9.shape).to.be.equal([2, 3]) expect(nd9.length).to.be.equal(6) }) + + describe("atFirstAxis", () => { + it("should throw an error if dimension=0", () => { + const nd = new Uint8NDArray([42], []) + expect(() => atFirstAxis(nd, 0)).to.throw() + }) + + it("should return value for dimension=1", () => { + const nd = new Uint8NDArray([2, 4, 42, 2442]) + expect(atFirstAxis(nd, 2)).to.be.equal(42) + + // First axis index out of bounds + expect(() => atFirstAxis(nd, 4)).to.throw() + }) + + it("should return new ndarray for dimension=2", () => { + const nd = new Uint8NDArray([1, 2, 3, 4], [2, 2]) + expect(atFirstAxis(nd, 0)).to.be.equal(new Uint8NDArray([1, 2], [2])) + expect(atFirstAxis(nd, 1)).to.be.equal(new Uint8NDArray([3, 4], [2])) + + // First axis index out of bounds + expect(() => atFirstAxis(nd, 2)).to.throw() + }) + + it("should return new ndarray for dimension=3", () => { + const nd = new Uint8NDArray([1, 2, 3, 4], [2, 2, 1]) + expect(atFirstAxis(nd, 0)).to.be.equal(new Uint8NDArray([1, 2], [2, 1])) + expect(atFirstAxis(nd, 1)).to.be.equal(new Uint8NDArray([3, 4], [2, 1])) + + // First axis index out of bounds + expect(() => atFirstAxis(nd, 2)).to.throw() + }) + + // And so on and so forth for dimension > 3 + }) }) diff --git a/bokehjs/test/unit/models/sources/column_data_source.ts b/bokehjs/test/unit/models/sources/column_data_source.ts index 163deb8f2d5..45d58b61aad 100644 --- a/bokehjs/test/unit/models/sources/column_data_source.ts +++ b/bokehjs/test/unit/models/sources/column_data_source.ts @@ -189,4 +189,94 @@ describe("column_data_source module", () => { // TODO ["d15", new Map([[0, "a"], [1, "b"], [2, "c"]])], ])) }) + + describe("generate_rows", () => { + it("should return empty for empty data source", () => { + const cds = new ColumnDataSource() + expect(Array.from(cds.row_generator())).to.be.equal([]) + }) + + it("should return column names as header row", () => { + const cds = new ColumnDataSource({data: { + foo: [], + bar: [], + }}) + expect(cds.row_generator().next().value).to.be.equal(["foo", "bar"]) + }) + + it("should transpose data", () => { + const cds = new ColumnDataSource({data: { + foo: [1, 2, 3], + bar: ["a", "b", "c"], + }}) + expect(Array.from(cds.row_generator())).to.be.equal([ + ["foo", "bar"], + [1, "a"], + [2, "b"], + [3, "c"], + ]) + }) + + it("should handle inconsistent-length columns", () => { + const cds = new ColumnDataSource({data: { + foo: [1, 2, 3], + bar: ["a", "b"], + }}) + expect(Array.from(cds.row_generator())).to.be.equal([ + ["foo", "bar"], + [1, "a"], + [2, "b"], + ]) + }) + }) + + describe("to_csv", () => { + it("should return empty string for empty data source", () => { + const cds = new ColumnDataSource() + expect(cds.to_csv()).to.be.equal("") + + const cds2 = new ColumnDataSource({data: {}}) + expect(cds2.to_csv()).to.be.equal("") + }) + + it("should handle empty columns", () => { + const cds = new ColumnDataSource({data: { + foo: [], + }}) + expect(cds.to_csv()).to.be.equal("foo\n") + + const cds2 = new ColumnDataSource({data: { + foo: [], + bar: [], + }}) + expect(cds2.to_csv()).to.be.equal("foo,bar\n") + }) + + it("should handle single column", () => { + const cds = new ColumnDataSource({data: { + foo: [1], + }}) + expect(cds.to_csv()).to.be.equal("foo\n1\n") + }) + + it("should treat 1-dimensional ndarray just like a normal array", () => { + const cds = new ColumnDataSource({data: { + foo: ndarray([1, 0, 1], {dtype: "bool", shape: [3]}), + bar: ndarray([10, 9, 8], {dtype: "uint8", shape: [3]}), + }}) + expect(cds.to_csv()).to.be.equal("foo,bar\ntrue,10\nfalse,9\ntrue,8\n") + }) + + it("should pack values for ndarray with dimension > 1", () => { + const cds = new ColumnDataSource({data: { + foo: ndarray([255, 0, 0, 0, 255, 0], {dtype: "uint8", shape: [2, 3]}), + bar: ndarray([0.5, 3.5, 10.25, -0.125, 3.75, 0.25, 0.5, -0.125], {dtype: "float32", shape: [2, 4]}), + }}) + expect(cds.to_csv()).to.be.equal( + "foo,bar\n" + + "\"Uint8Array([255, 0, 0])\",\"Float32Array([0.5, 3.5, 10.25, -0.125])\"\n" + + "\"Uint8Array([0, 255, 0])\",\"Float32Array([3.75, 0.25, 0.5, -0.125])\"\n", + ) + }) + }) }) diff --git a/docs/bokeh/source/docs/releases/3.8.0.rst b/docs/bokeh/source/docs/releases/3.8.0.rst index 1de32a15098..492956bc7dc 100644 --- a/docs/bokeh/source/docs/releases/3.8.0.rst +++ b/docs/bokeh/source/docs/releases/3.8.0.rst @@ -7,3 +7,4 @@ Bokeh version ``3.8.0`` (August 2025) is a minor milestone of Bokeh project. * added support for session reconnect, connection events and UI notifications (:bokeh-pull:`14201`, :bokeh-pull:`14576`) * Added support for ``Plot`` side panel layouts and ``SizeBar`` annotation (:bokeh-pull:`14487`) +* Added new method in BokehJS to export ColumnDataSource as CSV (:bokeh-pull:`14519`) diff --git a/src/bokeh/core/json_encoder.py b/src/bokeh/core/json_encoder.py index 8cd72f5b8f8..cbbbce79bcc 100644 --- a/src/bokeh/core/json_encoder.py +++ b/src/bokeh/core/json_encoder.py @@ -73,7 +73,7 @@ def serialize_json(obj: Any | Serialized[Any], *, pretty: bool | None = None, in Convert an object or a serialized representation to a JSON string. This function accepts Python-serializable objects and converts them to - a JSON string. This function does not perform any advaced serialization, + a JSON string. This function does not perform any advanced serialization, in particular it won't serialize Bokeh models or numpy arrays. For that, use :class:`bokeh.core.serialization.Serializer` class, which handles serialization of all types of objects that may be encountered in Bokeh. @@ -130,7 +130,7 @@ def serialize_json(obj: Any | Serialized[Any], *, pretty: bool | None = None, in >>> serialize_json(rep) '{"type":"map","entries":[["b",1677283200000.0],["a",{"type":"ndarray","array":' - "{"type":"bytes","data":"AAAAAAEAAAACAAAA"},"shape":[3],"dtype":"int32","order":"little"}]]}' + '{"type":"bytes","data":"AAAAAAEAAAACAAAA"},"shape":[3],"dtype":"int32","order":"little"}]]}' .. note::