From 9fadc5191c97ce75aadcdbe9a6582eb1736d6913 Mon Sep 17 00:00:00 2001 From: Andrei Date: Tue, 8 Oct 2024 17:37:46 +0400 Subject: [PATCH 1/5] Agg jupyter integration for lets-plot-kotlin and lets-plot-kotlin-geotools --- build.gradle.kts | 3 + gradle.properties | 6 ++ plot-api/build.gradle.kts | 10 ++ .../frontend/NotebookFrontendContext.kt | 5 + .../jetbrains/letsPlot/jupyter/Integration.kt | 98 +++++++++++++++++++ .../letsPlot/jupyter/JupyterConfig.kt | 21 ++++ .../jupyter/NotebookRenderingContext.kt | 81 +++++++++++++++ .../letsPlot/jupyter/containerUtil.kt | 42 ++++++++ .../letsPlot/jupyter/serializeSpec.kt | 39 ++++++++ .../jetbrains/letsPlot/jupyter/GGBunchTest.kt | 42 ++++++++ .../jetbrains/letsPlot/jupyter/JupyterTest.kt | 42 ++++++++ .../jetbrains/letsPlot/jupyter/PlotTest.kt | 25 +++++ .../letsPlot/jupyter/SubPlotsFigureTest.kt | 31 ++++++ .../jupyter/GGBunch output in jupyter.out | 1 + .../jupyter/Plot output in jupyter.out | 1 + .../SubPlotsFigure output in jupyter.out | 1 + settings.gradle.kts | 6 ++ toolkit/geotools/build.gradle.kts | 9 ++ .../toolkit/geotools/jupyter/Integration.kt | 10 ++ .../geotools/jupyter/NaturalEarthTest.kt | 46 +++++++++ 20 files changed, 519 insertions(+) create mode 100644 plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/Integration.kt create mode 100644 plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/JupyterConfig.kt create mode 100644 plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/NotebookRenderingContext.kt create mode 100644 plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/containerUtil.kt create mode 100644 plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/serializeSpec.kt create mode 100644 plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/GGBunchTest.kt create mode 100644 plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/JupyterTest.kt create mode 100644 plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/PlotTest.kt create mode 100644 plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/SubPlotsFigureTest.kt create mode 100644 plot-api/src/jvmTest/resources/jupyter/GGBunch output in jupyter.out create mode 100644 plot-api/src/jvmTest/resources/jupyter/Plot output in jupyter.out create mode 100644 plot-api/src/jvmTest/resources/jupyter/SubPlotsFigure output in jupyter.out create mode 100644 toolkit/geotools/src/main/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/Integration.kt create mode 100644 toolkit/geotools/src/test/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/NaturalEarthTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 6729a9c7f..8933ebac0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,6 +14,9 @@ plugins { id("io.codearte.nexus-staging") apply false id("io.github.gradle-nexus.publish-plugin") + + id("com.google.devtools.ksp") apply false + // kotlin("jupyter.api") apply false } val localProps = Properties() diff --git a/gradle.properties b/gradle.properties index 599f6aadb..db36a57ca 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,6 +12,9 @@ kotlin.code.style=official kotlin.daemon.jvmargs=-Xmx2048M kotlin.jvm.target.validation.mode=error +# Jupyter API +kotlin.jupyter.add.scanner=false + # Versions kotlin.version=1.9.25 datetime.version=0.3.2 @@ -23,6 +26,9 @@ dokka.version=1.9.20 nexusStaging.version=0.30.0 nexusPublish.version=1.3.0 +ksp.version=1.9.25-1.0.20 +jupyterApi.version=0.12.0-308 + # Also update JS version in /demo/js-frontend-app/src/main/resources/index.html letsPlot.version=4.4.2-SNAPSHOT diff --git a/plot-api/build.gradle.kts b/plot-api/build.gradle.kts index 18e7a24c5..26fa5685f 100644 --- a/plot-api/build.gradle.kts +++ b/plot-api/build.gradle.kts @@ -7,6 +7,10 @@ plugins { kotlin("multiplatform") `maven-publish` signing + // Add the KSP plugin before the Jupyter API to avoid ksp versions incompatibility. + // May be removed when using further versions of the jupyter api + id("com.google.devtools.ksp") + kotlin("jupyter.api") version "0.12.0-308" } val letsPlotVersion = extra["letsPlot.version"] as String @@ -63,6 +67,8 @@ kotlin { dependencies { // assertj implementation("org.assertj:assertj-core:$assertjVersion") + + implementation("org.jetbrains.lets-plot:lets-plot-image-export:$letsPlotVersion") } } } @@ -173,6 +179,10 @@ tasks { } } +tasks.processJupyterApiResources { + libraryProducers = listOf("org.jetbrains.letsPlot.jupyter.Integration") +} + //task printIt { // print("${project.name}: ${uri(project.localMavenRepository)}") //} diff --git a/plot-api/src/commonMain/kotlin/org/jetbrains/letsPlot/frontend/NotebookFrontendContext.kt b/plot-api/src/commonMain/kotlin/org/jetbrains/letsPlot/frontend/NotebookFrontendContext.kt index ddf7f801b..99a64dae3 100644 --- a/plot-api/src/commonMain/kotlin/org/jetbrains/letsPlot/frontend/NotebookFrontendContext.kt +++ b/plot-api/src/commonMain/kotlin/org/jetbrains/letsPlot/frontend/NotebookFrontendContext.kt @@ -5,6 +5,7 @@ package org.jetbrains.letsPlot.frontend +import org.jetbrains.letsPlot.Figure import org.jetbrains.letsPlot.FrontendContext import org.jetbrains.letsPlot.GGBunch import org.jetbrains.letsPlot.core.util.PlotHtmlHelper @@ -50,6 +51,10 @@ class NotebookFrontendContext( return getDisplayHtml(plotBunch.toSpec()) } + fun getHtml(figure: Figure): String { + return getDisplayHtml(figure.toSpec()) + } + // Used by alternative kotlin lets-plot API // https://github.com/nikitinas/lets-plot-dsl.git fun getDisplayHtml(rawSpec: MutableMap): String { diff --git a/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/Integration.kt b/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/Integration.kt new file mode 100644 index 000000000..2af8b101a --- /dev/null +++ b/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/Integration.kt @@ -0,0 +1,98 @@ +package org.jetbrains.letsPlot.jupyter + +import org.jetbrains.kotlinx.jupyter.api.* +import org.jetbrains.kotlinx.jupyter.api.libraries.ExecutionHost +import org.jetbrains.kotlinx.jupyter.api.libraries.JupyterIntegration +import org.jetbrains.kotlinx.jupyter.api.libraries.resources +import org.jetbrains.letsPlot.Figure +import org.jetbrains.letsPlot.LetsPlot +import org.jetbrains.letsPlot.core.util.PlotHtmlHelper.scriptUrl +import org.jetbrains.letsPlot.export.VersionChecker +import org.jetbrains.letsPlot.frontend.NotebookFrontendContext + +@Suppress("unused") +internal class Integration(private val notebook: Notebook, private val options: MutableMap) : + JupyterIntegration() { + + private val config = JupyterConfig() + private lateinit var frontendContext: NotebookFrontendContext + + + override fun Builder.onLoaded() { + + import("org.jetbrains.letsPlot.*") + import("org.jetbrains.letsPlot.geom.*") + import("org.jetbrains.letsPlot.geom.extras.*") + import("org.jetbrains.letsPlot.stat.*") + import("org.jetbrains.letsPlot.label.*") + import("org.jetbrains.letsPlot.scale.*") + import("org.jetbrains.letsPlot.facet.*") + import("org.jetbrains.letsPlot.sampling.*") + import("org.jetbrains.letsPlot.export.*") + import("org.jetbrains.letsPlot.tooltips.*") + import("org.jetbrains.letsPlot.annotations.*") + import("org.jetbrains.letsPlot.themes.*") + import("org.jetbrains.letsPlot.font.*") + import("org.jetbrains.letsPlot.coord.*") + import("org.jetbrains.letsPlot.pos.*") + import("org.jetbrains.letsPlot.bistro.corr.*") + import("org.jetbrains.letsPlot.bistro.qq.*") + import("org.jetbrains.letsPlot.bistro.joint.*") + import("org.jetbrains.letsPlot.bistro.residual.*") + import("org.jetbrains.letsPlot.bistro.waterfall.*") + import("org.jetbrains.letsPlot.intern.toSpec") + import("org.jetbrains.letsPlot.spatial.SpatialDataset") + + + onLoaded { + // Take integration options from descriptor by default; + // If used via Kotlin Notebook plugin as a dependency, + // provide defaults (versions from `VersionChecker` + // and empty `isolatedFrame`) + val api = options["api"] ?: VersionChecker.letsPlotKotlinAPIVersion + val js = options["js"] ?: VersionChecker.letsPlotJsVersion + val isolatedFrame = options["isolatedFrame"] ?: "" + val isolatedFrameParam = if (isolatedFrame.isNotEmpty()) isolatedFrame.toBoolean() else null + frontendContext = LetsPlot.setupNotebook(js, isolatedFrameParam) { display(HTML(it), null) } + LetsPlot.apiVersion = api + // Load library JS + display(HTML(frontendContext.getConfigureHtml()), null) + // add figure renders AFTER frontendContext initialization + addRenders(js) + declare("letsPlotNotebookConfig" to config) + } + } + + + private fun Builder.addRenders(jsVersion: String) { + var firstFigureRendered = false + resources { + js("letsPlotJs") { + url(scriptUrl(jsVersion)) + } + } + renderWithHostTemp
{ host, value -> + // For cases when Integration is added via Kotlin Notebook project dependency; + // display configure HTML with the first `Figure rendering + if (!firstFigureRendered) { + firstFigureRendered = true + host.execute { display(HTML(frontendContext.getConfigureHtml()), null) } + } + NotebookRenderingContext(config, frontendContext).figureToMimeResult(value) + } + } + + + // copy-pasted from jupyter api; + // it is not possible to use `renderWithHost` directly because it is inline and built with JVM 11 + private inline fun Builder.renderWithHostTemp(noinline renderer: CodeCell.(ExecutionHost, T) -> Any) { + val execution = + ResultHandlerExecution { host, property -> + val currentCell = notebook.currentCell + ?: throw IllegalStateException("Current cell should not be null on renderer invocation") + FieldValue(renderer(currentCell, host, property.value as T), null) + } + addRenderer(SubtypeRendererTypeHandler(T::class, execution)) + } + +} diff --git a/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/JupyterConfig.kt b/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/JupyterConfig.kt new file mode 100644 index 000000000..e73b6fc48 --- /dev/null +++ b/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/JupyterConfig.kt @@ -0,0 +1,21 @@ +package org.jetbrains.letsPlot.jupyter + +/** + * Lets-Plot config for Jupyter. + */ +class JupyterConfig { + /** + * Is IDEA theme applied to plots in the notebook outputs rendered with Swing. + * + * Enabled by default. + */ + var themeApplied: Boolean = true + + /** + * Is Java Swing applied to plots in the notebook outputs. + * Otherwise, web (HTML + JS) rendering is used. + * + * Enabled by default. + */ + var swingEnabled: Boolean = true +} diff --git a/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/NotebookRenderingContext.kt b/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/NotebookRenderingContext.kt new file mode 100644 index 000000000..b52b8e4ba --- /dev/null +++ b/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/NotebookRenderingContext.kt @@ -0,0 +1,81 @@ +package org.jetbrains.letsPlot.jupyter + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import org.jetbrains.kotlinx.jupyter.api.MimeTypedResultEx +import org.jetbrains.kotlinx.jupyter.api.MimeTypes +import org.jetbrains.letsPlot.Figure +import org.jetbrains.letsPlot.awt.plot.PlotSvgExport +import org.jetbrains.letsPlot.frontend.NotebookFrontendContext +import org.jetbrains.letsPlot.intern.toSpec +import java.util.* + +internal class NotebookRenderingContext( + private val config: JupyterConfig, + private val frontendContext: NotebookFrontendContext +) { + /** + * Creates Mime JSON with two output options - HTML and application/plot. + * The HTML output is used in Jupyter Notebooks and Datalore (the other one is ignored). + * The application/plot is used in Kotlin Notebook when native rendering via Swing is enabled. + */ + private fun figureToMimeJson(figure: Figure): JsonObject { + val spec = figure.toSpec() + val html = frontendContext.getHtml(figure) + return buildJsonObject { + put(MimeTypes.HTML, JsonPrimitive(html)) + put("application/plot+json", buildJsonObject { + put("output_type", JsonPrimitive("lets_plot_spec")) + put("output", serializeSpec(spec)) + put("apply_color_scheme", JsonPrimitive(config.themeApplied)) + put("swing_enabled", JsonPrimitive(config.swingEnabled)) + }) + } + } + + /** + * Modifies an SVG by adding a new identifier and setting the width and height of the SVG to 100%, + * while retaining the original dimensions in the style for responsiveness. + * It also adds viewBox and preserveAspectRatio attributes to ensure the SVG scales properly without distortion. + * Updates SVG id with provided one. + */ + private fun updateSvg(svgString: String, id: String): String { + val regex = Regex("""width=["']([^"']*)["']\s*height=["']([^"']*)["']""") + return regex.replace(svgString) { + val currentWidth = it.groupValues[1] + val currentHeight = it.groupValues[2] + """id=$id width="100%" height="100%" style="max-width: ${currentWidth}px; max-height: ${currentHeight}px;" viewBox="0 0 $currentWidth $currentHeight" preserveAspectRatio="xMinYMin meet"""" + } + } + + /** + * Converts a `Figure` object into a hidden SVG embedded in an HTML
element. + * The SVG is made hidden using an inline + """.trimIndent() + + return mapOf(MimeTypes.HTML to JsonPrimitive(extraHTML)) + } + + fun figureToMimeResult(figure: Figure): MimeTypedResultEx { + val basicResult = figureToMimeJson(figure) + val extraSvg = figureToHiddenSvg(figure) + return MimeTypedResultEx( + basicResult extendedByJson extraSvg, + id = null, + metadataModifiers = emptyList() + ) + } +} \ No newline at end of file diff --git a/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/containerUtil.kt b/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/containerUtil.kt new file mode 100644 index 000000000..632e6cfd1 --- /dev/null +++ b/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/containerUtil.kt @@ -0,0 +1,42 @@ +package org.jetbrains.letsPlot.jupyter + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +internal fun Map.extendedBy(other: Map, join: (V, V) -> V): Map { + return (this + other).mapValues { (k, v) -> + if (k in this && k in other) + join(this[k]!!, other[k]!!) + else + v + } +} + +@OptIn(ExperimentalContracts::class) +private inline fun bothOfType(a: Any, b: Any): Boolean { + contract { returns(true) implies (a is T && b is T) } + return a is T && b is T +} + +@OptIn(ExperimentalContracts::class) +private inline fun bothOfTypeAnd(a: Any, b: Any, condition: (T) -> Boolean): Boolean { + contract { returns(true) implies (a is T && b is T) } + return a is T && b is T && condition(a) && condition(b) +} + +internal infix fun Map.extendedByJson(other: Map): JsonObject { + val map = this.extendedBy(other) { a, b -> + // This logic might be enhanced + when { + bothOfTypeAnd(a, b) { it.isString } -> JsonPrimitive(a.content + b.content) + bothOfType(a, b) -> JsonArray(a + b) + bothOfType(a, b) -> JsonObject(a + b) + else -> a + } + } + return JsonObject(map) +} diff --git a/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/serializeSpec.kt b/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/serializeSpec.kt new file mode 100644 index 000000000..f11e1213e --- /dev/null +++ b/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/serializeSpec.kt @@ -0,0 +1,39 @@ +package org.jetbrains.letsPlot.jupyter + +import kotlinx.serialization.json.* + +/** + * Serialize Lets-Plot spec into `JsonElement` + */ +internal fun serializeSpec(spec: Map<*, *>): JsonElement { + return serialize(spec) +} + +private fun serializeAny(obj: Any?): JsonElement { + return when (obj) { + null -> JsonNull + is Map<*, *> -> serialize(obj) + is List<*> -> serialize(obj) + is String -> JsonPrimitive(obj) + is Boolean -> JsonPrimitive(obj) + is Number -> JsonPrimitive(obj) + else -> error("Don't know how to parse object [$obj] of class ${obj::class}") + } +} + +private fun serialize(map: Map<*, *>): JsonObject { + return buildJsonObject { + for ((key, value) in map) { + if (key !is String) error("Map key [$key] is of type ${key?.let { it::class }}. Don't know how to serialize it.") + put(key, serializeAny(value)) + } + } +} + +private fun serialize(list: List<*>): JsonArray { + return buildJsonArray { + for (value in list) { + add(serializeAny(value)) + } + } +} diff --git a/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/GGBunchTest.kt b/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/GGBunchTest.kt new file mode 100644 index 000000000..12ca64b2b --- /dev/null +++ b/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/GGBunchTest.kt @@ -0,0 +1,42 @@ +package org.jetbrains.letsPlot.jupyter + +import kotlin.test.Test + +internal class GGBunchTest : JupyterTest() { + + private val bunch = """ + val data1 = mapOf( + "x" to listOf(1, 2, 3, 4), + "y" to listOf(4, 6, 5, 7) + ) + val plot1 = ggplot(data1) + + geomPoint(size = 5, color = "red", shape = 16) { x = "x"; y = "y" } + + ggtitle("Plot 1") + + ggsize(300, 300) + + val data2 = mapOf( + "x" to listOf(1, 2, 3, 4, 5), + "y" to listOf(1, 2, 3, 2, 1) + ) + val plot2 = ggplot(data2) + + geomLine(size = 2, color = "blue") { x = "x"; y = "y" } + + ggtitle("Plot 2") + + theme(axisTitleX = elementText(color = "blue", size = 14), + axisTitleY = elementText(color = "blue", size = 14)) + + ggsize(300, 300) + + val bunch = GGBunch() + .addPlot(plot1, 0, 0) + .addPlot(plot2, 350, 0) + + bunch + """.trimIndent() + + @Test + fun `compilation of GGBunch in jupyter`() = bunch.checkCompilation() + + @Test + fun `GGBunch output in jupyter`() { + assertOutput(execRendered(bunch)) + } +} \ No newline at end of file diff --git a/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/JupyterTest.kt b/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/JupyterTest.kt new file mode 100644 index 000000000..fcae62aa9 --- /dev/null +++ b/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/JupyterTest.kt @@ -0,0 +1,42 @@ +package org.jetbrains.letsPlot.jupyter + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import org.jetbrains.kotlinx.jupyter.api.Code +import org.jetbrains.kotlinx.jupyter.api.MimeTypedResultEx +import org.jetbrains.kotlinx.jupyter.testkit.JupyterReplTestCase +import org.jetbrains.kotlinx.jupyter.testkit.ReplProvider +import org.junit.Rule +import org.junit.rules.TestName +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.fail + +internal abstract class JupyterTest : JupyterReplTestCase( + ReplProvider.forLibrariesTesting(listOf("lets-plot-kotlin")) +) { + + @JvmField + @Rule + val testName: TestName = TestName() + + private val classLoader = (this::class as Any).javaClass.classLoader + + fun Code.checkCompilation() { + execRendered(this) + } + + fun assertOutput(actualOutputText: Any?) { + if (actualOutputText !is MimeTypedResultEx) fail("The output is not MimeTypedResultEx") + val outputDataObject = actualOutputText.toJson(JsonObject(mapOf()), null)["data"]?.jsonObject + assertNotNull(outputDataObject) + assertTrue(outputDataObject.contains("text/html")) + assertTrue(outputDataObject.contains("application/plot+json")) + + val resourcePath = "jupyter/${testName.methodName}.out" + val resource = classLoader.getResource(resourcePath) + assertNotNull(resource) + assertEquals(resource.readText(), outputDataObject["application/plot+json"].toString()) + } +} \ No newline at end of file diff --git a/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/PlotTest.kt b/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/PlotTest.kt new file mode 100644 index 000000000..bcfaecb62 --- /dev/null +++ b/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/PlotTest.kt @@ -0,0 +1,25 @@ +package org.jetbrains.letsPlot.jupyter + +import kotlin.test.Test + +internal class PlotTest : JupyterTest() { + + private val plot = """ + val data = mapOf( + "type" to listOf("X", "X", "Y", "Y", "X", "X", "Y", "X"), + "cond" to listOf("A", "B", "A", "A", "A", "A", "A", "B") + ) + + var p = letsPlot(data) + p += geomBar(color = "dark_green", alpha = .3) { x = "type"; fill = "cond" } + p + ggsize(700, 350) + """.trimIndent() + + @Test + fun `compilation of Plot in jupyter`() = plot.checkCompilation() + + @Test + fun `Plot output in jupyter`() { + assertOutput(execRendered(plot)) + } +} \ No newline at end of file diff --git a/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/SubPlotsFigureTest.kt b/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/SubPlotsFigureTest.kt new file mode 100644 index 000000000..449396435 --- /dev/null +++ b/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/SubPlotsFigureTest.kt @@ -0,0 +1,31 @@ +package org.jetbrains.letsPlot.jupyter + +import kotlin.test.Test + +internal class SubPlotsFigureTest : JupyterTest() { + + private val grid = """ + val data1 = mapOf( + "x" to listOf(1, 2, 3, 4), + "y" to listOf(4, 3, 2, 1) + ) + val plot1 = ggplot(data1) + geomPoint { x = "x"; y = "y" } + ggtitle("Plot 1") + + val data2 = mapOf( + "x" to listOf(1, 2, 3, 4), + "y" to listOf(1, 2, 3, 4) + ) + val plot2 = ggplot(data2) + geomLine { x = "x"; y = "y" } + ggtitle("Plot 2") + + val grid = gggrid(listOf(plot1, plot2)) + grid + """.trimIndent() + + @Test + fun `compilation of SubPlotsFigure in jupyter`() = grid.checkCompilation() + + @Test + fun `SubPlotsFigure output in jupyter`() { + assertOutput(execRendered(grid)) + } +} \ No newline at end of file diff --git a/plot-api/src/jvmTest/resources/jupyter/GGBunch output in jupyter.out b/plot-api/src/jvmTest/resources/jupyter/GGBunch output in jupyter.out new file mode 100644 index 000000000..e81b8b1dd --- /dev/null +++ b/plot-api/src/jvmTest/resources/jupyter/GGBunch output in jupyter.out @@ -0,0 +1 @@ +{"output_type":"lets_plot_spec","output":{"kind":"ggbunch","items":[{"feature_spec":{"ggtitle":{"text":"Plot 1"},"mapping":{},"data":{"x":[1.0,2.0,3.0,4.0],"y":[4.0,6.0,5.0,7.0]},"ggsize":{"width":300.0,"height":300.0},"kind":"plot","scales":[],"layers":[{"mapping":{"x":"x","y":"y"},"stat":"identity","color":"red","shape":16.0,"size":5.0,"position":"identity","geom":"point"}],"data_meta":{"series_annotations":[{"type":"int","column":"x"},{"type":"int","column":"y"}]}},"x":0,"y":0,"width":null,"height":null},{"feature_spec":{"ggtitle":{"text":"Plot 2"},"mapping":{},"data":{"x":[1.0,2.0,3.0,4.0,5.0],"y":[1.0,2.0,3.0,2.0,1.0]},"ggsize":{"width":300.0,"height":300.0},"kind":"plot","scales":[],"layers":[{"mapping":{"x":"x","y":"y"},"stat":"identity","color":"blue","size":2.0,"position":"identity","geom":"line"}],"theme":{"axis_title_y":{"color":"blue","size":14.0,"blank":false},"axis_title_x":{"color":"blue","size":14.0,"blank":false}},"data_meta":{"series_annotations":[{"type":"int","column":"x"},{"type":"int","column":"y"}]}},"x":350,"y":0,"width":null,"height":null}]},"apply_color_scheme":true,"swing_enabled":true} \ No newline at end of file diff --git a/plot-api/src/jvmTest/resources/jupyter/Plot output in jupyter.out b/plot-api/src/jvmTest/resources/jupyter/Plot output in jupyter.out new file mode 100644 index 000000000..059aec6c4 --- /dev/null +++ b/plot-api/src/jvmTest/resources/jupyter/Plot output in jupyter.out @@ -0,0 +1 @@ +{"output_type":"lets_plot_spec","output":{"mapping":{},"data":{"type":["X","X","Y","Y","X","X","Y","X"],"cond":["A","B","A","A","A","A","A","B"]},"ggsize":{"width":700.0,"height":350.0},"kind":"plot","scales":[],"layers":[{"mapping":{"x":"type","fill":"cond"},"stat":"count","color":"dark_green","alpha":0.3,"position":"stack","geom":"bar"}],"data_meta":{"series_annotations":[{"type":"str","column":"type"},{"type":"str","column":"cond"}]}},"apply_color_scheme":true,"swing_enabled":true} \ No newline at end of file diff --git a/plot-api/src/jvmTest/resources/jupyter/SubPlotsFigure output in jupyter.out b/plot-api/src/jvmTest/resources/jupyter/SubPlotsFigure output in jupyter.out new file mode 100644 index 000000000..509ad2425 --- /dev/null +++ b/plot-api/src/jvmTest/resources/jupyter/SubPlotsFigure output in jupyter.out @@ -0,0 +1 @@ +{"output_type":"lets_plot_spec","output":{"kind":"subplots","figures":[{"ggtitle":{"text":"Plot 1"},"mapping":{},"data":{"x":[1.0,2.0,3.0,4.0],"y":[4.0,3.0,2.0,1.0]},"kind":"plot","scales":[],"layers":[{"mapping":{"x":"x","y":"y"},"stat":"identity","position":"identity","geom":"point"}],"data_meta":{"series_annotations":[{"type":"int","column":"x"},{"type":"int","column":"y"}]}},{"ggtitle":{"text":"Plot 2"},"mapping":{},"data":{"x":[1.0,2.0,3.0,4.0],"y":[1.0,2.0,3.0,4.0]},"kind":"plot","scales":[],"layers":[{"mapping":{"x":"x","y":"y"},"stat":"identity","position":"identity","geom":"line"}],"data_meta":{"series_annotations":[{"type":"int","column":"x"},{"type":"int","column":"y"}]}}],"layout":{"name":"grid","ncol":2,"nrow":1,"fit":true,"align":false}},"apply_color_scheme":true,"swing_enabled":true} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index b11d4450f..53be626c6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,9 @@ pluginManagement { val nexusStagingVersion = extra["nexusStaging.version"] as String val nexusPublishVersion = extra["nexusPublish.version"] as String + val kspVersion = extra["ksp.version"] as String + // val jupyterApiVersion = extra["jupyterApi.version"] as String + kotlin("multiplatform") version kotlinVersion kotlin("jvm").version(kotlinVersion) @@ -23,6 +26,9 @@ pluginManagement { id("io.codearte.nexus-staging") version nexusStagingVersion id("io.github.gradle-nexus.publish-plugin") version nexusPublishVersion + + id("com.google.devtools.ksp") version kspVersion + // kotlin("jupyter.api") version jupyterApiVersion } } diff --git a/toolkit/geotools/build.gradle.kts b/toolkit/geotools/build.gradle.kts index eac865da1..763df0d94 100644 --- a/toolkit/geotools/build.gradle.kts +++ b/toolkit/geotools/build.gradle.kts @@ -7,6 +7,11 @@ plugins { kotlin("jvm") `maven-publish` signing + // Add the KSP plugin before the Jupyter API to avoid ksp versions incompatibility. + // May be removed when using further versions of the jupyter api + id("com.google.devtools.ksp") + // Use jupyter API version compatible with java 1.8 + kotlin("jupyter.api") version "0.12.0-139" } val geoToolsVersion = extra["geotools.version"] as String @@ -102,3 +107,7 @@ signing { sign(publishing.publications) } } + +tasks.processJupyterApiResources { + libraryProducers = listOf("org.jetbrains.letsPlot.toolkit.geotools.jupyter.Integration") +} diff --git a/toolkit/geotools/src/main/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/Integration.kt b/toolkit/geotools/src/main/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/Integration.kt new file mode 100644 index 000000000..2984ccdec --- /dev/null +++ b/toolkit/geotools/src/main/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/Integration.kt @@ -0,0 +1,10 @@ +package org.jetbrains.letsPlot.toolkit.geotools.jupyter + +import org.jetbrains.kotlinx.jupyter.api.libraries.JupyterIntegration + +@Suppress("unused") +class Integration: JupyterIntegration() { + override fun Builder.onLoaded() { + import("org.jetbrains.letsPlot.toolkit.geotools.toSpatialDataset") + } +} diff --git a/toolkit/geotools/src/test/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/NaturalEarthTest.kt b/toolkit/geotools/src/test/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/NaturalEarthTest.kt new file mode 100644 index 000000000..a4c3c9999 --- /dev/null +++ b/toolkit/geotools/src/test/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/NaturalEarthTest.kt @@ -0,0 +1,46 @@ +package org.jetbrains.letsPlot.toolkit.geotools.jupyter + +import org.jetbrains.kotlinx.jupyter.testkit.JupyterReplTestCase +import org.jetbrains.kotlinx.jupyter.testkit.ReplProvider +import kotlin.test.Test + +class NaturalEarthTest : JupyterReplTestCase( + ReplProvider.forLibrariesTesting(listOf("lets-plot-kotlin-geotools")) +) { + private val geotoolsDependencies = """ + USE { + repositories { + maven("https://repo.osgeo.org/repository/release") + } + + dependencies { + implementation("org.geotools:gt-main:32.0") + implementation("org.geotools:gt-shapefile:32.0") + } + } + """.trimIndent() + + private val naturalEarthMap = """ + import org.geotools.data.shapefile.ShapefileDataStoreFactory + import org.geotools.data.simple.SimpleFeatureCollection + import java.net.URL + + val factory = ShapefileDataStoreFactory() + val worldFeatures : SimpleFeatureCollection = with("naturalearth_lowres") { + val url = "https://raw.githubusercontent.com/JetBrains/lets-plot-kotlin/master/docs/examples/shp/${'$'}this/${'$'}this.shp" + factory.createDataStore(URL(url)).featureSource.features + } + + val world = worldFeatures.toSpatialDataset(10) + letsPlot() + + geomMap(map = world) + + ggsize(700, 400) + + themeVoid() + """.trimIndent() + + @Test + fun `Plot output in jupyter`() { + execRendered(geotoolsDependencies) + execRendered(naturalEarthMap) + } +} \ No newline at end of file From 831de94f5bed13de877dd8c9002179b4cc9dc133 Mon Sep 17 00:00:00 2001 From: Andrei Date: Sat, 12 Oct 2024 17:54:59 +0400 Subject: [PATCH 2/5] Extract integrations to separate modules and artifacts with all LP/LPK implementations; extract spec serialization to util module --- build.gradle.kts | 4 +- gradle.properties | 2 +- plot-api/build.gradle.kts | 31 ++++-- .../frontend/NotebookFrontendContext.kt | 5 - .../letsPlot/jupyter/serializeSpec.kt | 39 ------- settings.gradle.kts | 12 +- toolkit/geotools-jupyter/build.gradle.kts | 103 +++++++++++++++++ .../toolkit/geotools/jupyter/Integration.kt | 18 +++ .../geotools/jupyter/NaturalEarthTest.kt | 14 +-- toolkit/geotools/build.gradle.kts | 9 -- .../toolkit/geotools/jupyter/Integration.kt | 10 -- toolkit/jupyter/build.gradle.kts | 105 ++++++++++++++++++ .../letsPlot/toolkit}/jupyter/Integration.kt | 44 +++----- .../toolkit}/jupyter/JupyterConfig.kt | 2 +- .../jupyter/NotebookRenderingContext.kt | 6 +- .../toolkit/jupyter/util}/containerUtil.kt | 2 +- .../letsPlot/toolkit}/jupyter/GGBunchTest.kt | 9 +- .../letsPlot/toolkit}/jupyter/JupyterTest.kt | 14 +-- .../letsPlot/toolkit}/jupyter/PlotTest.kt | 9 +- .../toolkit}/jupyter/SubPlotsFigureTest.kt | 9 +- .../jupyter/GGBunch output in jupyter.out | 0 .../jupyter/Plot output in jupyter.out | 0 .../SubPlotsFigure output in jupyter.out | 0 toolkit/util/build.gradle.kts | 66 +++++++++++ .../toolkit/jupyter/util/serializeSpec.kt | 84 ++++++++++++++ 25 files changed, 455 insertions(+), 142 deletions(-) delete mode 100644 plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/serializeSpec.kt create mode 100644 toolkit/geotools-jupyter/build.gradle.kts create mode 100644 toolkit/geotools-jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/Integration.kt rename toolkit/{geotools => geotools-jupyter}/src/test/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/NaturalEarthTest.kt (70%) delete mode 100644 toolkit/geotools/src/main/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/Integration.kt create mode 100644 toolkit/jupyter/build.gradle.kts rename {plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot => toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit}/jupyter/Integration.kt (67%) rename {plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot => toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit}/jupyter/JupyterConfig.kt (90%) rename {plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot => toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit}/jupyter/NotebookRenderingContext.kt (93%) rename {plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter => toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/util}/containerUtil.kt (96%) rename {plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot => toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit}/jupyter/GGBunchTest.kt (80%) rename {plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot => toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit}/jupyter/JupyterTest.kt (78%) rename {plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot => toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit}/jupyter/PlotTest.kt (67%) rename {plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot => toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit}/jupyter/SubPlotsFigureTest.kt (71%) rename {plot-api/src/jvmTest => toolkit/jupyter/src/test}/resources/jupyter/GGBunch output in jupyter.out (100%) rename {plot-api/src/jvmTest => toolkit/jupyter/src/test}/resources/jupyter/Plot output in jupyter.out (100%) rename {plot-api/src/jvmTest => toolkit/jupyter/src/test}/resources/jupyter/SubPlotsFigure output in jupyter.out (100%) create mode 100644 toolkit/util/build.gradle.kts create mode 100644 toolkit/util/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/util/serializeSpec.kt diff --git a/build.gradle.kts b/build.gradle.kts index 8933ebac0..6fc8c0a02 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,8 +15,10 @@ plugins { id("io.github.gradle-nexus.publish-plugin") + // Add the KSP plugin before the Jupyter API to avoid ksp versions incompatibility. + // May be removed when using further versions of the jupyter api id("com.google.devtools.ksp") apply false - // kotlin("jupyter.api") apply false + kotlin("jupyter.api") apply false } val localProps = Properties() diff --git a/gradle.properties b/gradle.properties index db36a57ca..8fd252078 100644 --- a/gradle.properties +++ b/gradle.properties @@ -27,7 +27,7 @@ nexusStaging.version=0.30.0 nexusPublish.version=1.3.0 ksp.version=1.9.25-1.0.20 -jupyterApi.version=0.12.0-308 +jupyterApi.version=0.12.0-313 # Also update JS version in /demo/js-frontend-app/src/main/resources/index.html letsPlot.version=4.4.2-SNAPSHOT diff --git a/plot-api/build.gradle.kts b/plot-api/build.gradle.kts index 26fa5685f..d7aa86a33 100644 --- a/plot-api/build.gradle.kts +++ b/plot-api/build.gradle.kts @@ -7,10 +7,6 @@ plugins { kotlin("multiplatform") `maven-publish` signing - // Add the KSP plugin before the Jupyter API to avoid ksp versions incompatibility. - // May be removed when using further versions of the jupyter api - id("com.google.devtools.ksp") - kotlin("jupyter.api") version "0.12.0-308" } val letsPlotVersion = extra["letsPlot.version"] as String @@ -43,7 +39,7 @@ kotlin { named("jvmMain") { dependencies { - implementation("io.github.microutils:kotlin-logging:$kotlinLoggingVersion") + implementation("io.github.microutils:kotlin-logging:$kotlinLoggingVersion") api("org.jetbrains.lets-plot:lets-plot-common:$letsPlotVersion") // Use "-jvm" variant to work around the issue where LPK JS (IR) artefact becomes dependent on // the "kotlinx-datetime". @@ -67,8 +63,6 @@ kotlin { dependencies { // assertj implementation("org.assertj:assertj-core:$assertjVersion") - - implementation("org.jetbrains.lets-plot:lets-plot-image-export:$letsPlotVersion") } } } @@ -179,11 +173,26 @@ tasks { } } -tasks.processJupyterApiResources { - libraryProducers = listOf("org.jetbrains.letsPlot.jupyter.Integration") -} - //task printIt { // print("${project.name}: ${uri(project.localMavenRepository)}") //} +// Provide jvm resources to jupyter module +// https://youtrack.jetbrains.com/issue/KTIJ-16582/Consumer-Kotlin-JVM-library-cannot-access-a-Kotlin-Multiplatform-JVM-target-resources-in-multi-module-Gradle-project +tasks { + val jvmProcessResources by getting + val fixMissingResources by creating(Copy::class) { + dependsOn(jvmProcessResources) + from(layout.buildDirectory.dir("processedResources/jvm/main")) + into(layout.buildDirectory.dir("classes/kotlin/jvm/main")) + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } + val jvmJar by getting(Jar::class) { + dependsOn(fixMissingResources) + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } +} + +tasks.named("jvmTest") { + dependsOn("fixMissingResources") +} diff --git a/plot-api/src/commonMain/kotlin/org/jetbrains/letsPlot/frontend/NotebookFrontendContext.kt b/plot-api/src/commonMain/kotlin/org/jetbrains/letsPlot/frontend/NotebookFrontendContext.kt index 99a64dae3..ddf7f801b 100644 --- a/plot-api/src/commonMain/kotlin/org/jetbrains/letsPlot/frontend/NotebookFrontendContext.kt +++ b/plot-api/src/commonMain/kotlin/org/jetbrains/letsPlot/frontend/NotebookFrontendContext.kt @@ -5,7 +5,6 @@ package org.jetbrains.letsPlot.frontend -import org.jetbrains.letsPlot.Figure import org.jetbrains.letsPlot.FrontendContext import org.jetbrains.letsPlot.GGBunch import org.jetbrains.letsPlot.core.util.PlotHtmlHelper @@ -51,10 +50,6 @@ class NotebookFrontendContext( return getDisplayHtml(plotBunch.toSpec()) } - fun getHtml(figure: Figure): String { - return getDisplayHtml(figure.toSpec()) - } - // Used by alternative kotlin lets-plot API // https://github.com/nikitinas/lets-plot-dsl.git fun getDisplayHtml(rawSpec: MutableMap): String { diff --git a/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/serializeSpec.kt b/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/serializeSpec.kt deleted file mode 100644 index f11e1213e..000000000 --- a/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/serializeSpec.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.jetbrains.letsPlot.jupyter - -import kotlinx.serialization.json.* - -/** - * Serialize Lets-Plot spec into `JsonElement` - */ -internal fun serializeSpec(spec: Map<*, *>): JsonElement { - return serialize(spec) -} - -private fun serializeAny(obj: Any?): JsonElement { - return when (obj) { - null -> JsonNull - is Map<*, *> -> serialize(obj) - is List<*> -> serialize(obj) - is String -> JsonPrimitive(obj) - is Boolean -> JsonPrimitive(obj) - is Number -> JsonPrimitive(obj) - else -> error("Don't know how to parse object [$obj] of class ${obj::class}") - } -} - -private fun serialize(map: Map<*, *>): JsonObject { - return buildJsonObject { - for ((key, value) in map) { - if (key !is String) error("Map key [$key] is of type ${key?.let { it::class }}. Don't know how to serialize it.") - put(key, serializeAny(value)) - } - } -} - -private fun serialize(list: List<*>): JsonArray { - return buildJsonArray { - for (value in list) { - add(serializeAny(value)) - } - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 53be626c6..bd38c5c45 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,7 +17,7 @@ pluginManagement { val nexusPublishVersion = extra["nexusPublish.version"] as String val kspVersion = extra["ksp.version"] as String - // val jupyterApiVersion = extra["jupyterApi.version"] as String + val jupyterApiVersion = extra["jupyterApi.version"] as String kotlin("multiplatform") version kotlinVersion kotlin("jvm").version(kotlinVersion) @@ -28,7 +28,7 @@ pluginManagement { id("io.github.gradle-nexus.publish-plugin") version nexusPublishVersion id("com.google.devtools.ksp") version kspVersion - // kotlin("jupyter.api") version jupyterApiVersion + kotlin("jupyter.api") version jupyterApiVersion } } @@ -45,6 +45,9 @@ include("js-frontend-app") include("geotools") include("geotools-batik") include("dokka") +include("jupyter") +include("geotools-jupyter") +include("util") project(":demo-common").projectDir = File("./demo/demo-common") project(":jvm-javafx").projectDir = File("./demo/jvm-javafx") @@ -56,3 +59,8 @@ project(":geotools").projectDir = File("./toolkit/geotools") project(":geotools-batik").projectDir = File("./demo/geotools-batik") project(":dokka").projectDir = File("./docs/dokka") + +project(":jupyter").projectDir = File("./toolkit/jupyter") +project(":geotools-jupyter").projectDir = File("./toolkit/geotools-jupyter") + +project(":util").projectDir = File("./toolkit/util") \ No newline at end of file diff --git a/toolkit/geotools-jupyter/build.gradle.kts b/toolkit/geotools-jupyter/build.gradle.kts new file mode 100644 index 000000000..724ab6894 --- /dev/null +++ b/toolkit/geotools-jupyter/build.gradle.kts @@ -0,0 +1,103 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") + `maven-publish` + signing + id("com.google.devtools.ksp") + kotlin("jupyter.api") +} + +repositories { + mavenCentral() +} + +val geoToolsVersion = extra["geotools.version"] as String + +dependencies { + implementation(projects.plotApi) + // basic LPK jupyter integration + implementation(projects.jupyter) + + // geotools implementations + implementation(projects.geotools) + implementation("org.geotools:gt-main:$geoToolsVersion") + implementation("org.geotools:gt-geojson:$geoToolsVersion") + + testImplementation(kotlin("test")) +} + +kotlin { + jvmToolchain(11) +} +tasks.withType().all { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } +} + +tasks.withType().all { + sourceCompatibility = JavaVersion.VERSION_11.toString() + targetCompatibility = JavaVersion.VERSION_11.toString() +} + +tasks.processJupyterApiResources { + libraryProducers = listOf("org.jetbrains.letsPlot.toolkit.geotools.jupyter.Integration") +} + +val artifactBaseName = "lets-plot-kotlin-geotools-jupyter" +val artifactGroupId = project.group as String +val artifactVersion = project.version as String + +afterEvaluate { + + publishing { + publications { + // Build artifact with all dependencies. + create("letsPlotKotlinGeotoolsJupyter") { + + groupId = artifactGroupId + artifactId = artifactBaseName + version = artifactVersion + + from(components["java"]) + + pom { + name.set("Lets-Plot Kotlin Geotools Jupyter Integration") + description.set( + "Lets-Plot Kotlin Geotools Integration For Kotlin Jupyter Kernel." + ) + url.set("https://github.com/JetBrains/lets-plot-kotlin") + licenses { + license { + name.set("MIT") + url.set("https://raw.githubusercontent.com/JetBrains/lets-plot-kotlin/master/LICENSE") + } + } + developers { + developer { + id.set("jetbrains") + name.set("JetBrains") + email.set("lets-plot@jetbrains.com") + } + } + scm { + url.set("https://github.com/JetBrains/lets-plot-kotlin") + } + } + } + } + + repositories { + mavenLocal { + val localMavenRepository: String by project + url = uri(localMavenRepository) + } + } + } +} +signing { + if (!(project.version as String).contains("SNAPSHOT")) { + sign(publishing.publications) + } +} diff --git a/toolkit/geotools-jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/Integration.kt b/toolkit/geotools-jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/Integration.kt new file mode 100644 index 000000000..ed84efc82 --- /dev/null +++ b/toolkit/geotools-jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/Integration.kt @@ -0,0 +1,18 @@ +package org.jetbrains.letsPlot.toolkit.geotools.jupyter + +import org.jetbrains.kotlinx.jupyter.api.Notebook +import org.jetbrains.kotlinx.jupyter.api.libraries.JupyterIntegration +import org.jetbrains.kotlinx.jupyter.api.libraries.repositories +import org.jetbrains.letsPlot.export.VersionChecker + +@Suppress("unused") +class Integration(private val notebook: Notebook, private val options: MutableMap) : + JupyterIntegration() { + private val api = options["api"] ?: VersionChecker.letsPlotKotlinAPIVersion + override fun Builder.onLoaded() { + repositories { + maven("https://repo.osgeo.org/repository/release") + } + import("org.jetbrains.letsPlot.toolkit.geotools.toSpatialDataset") + } +} diff --git a/toolkit/geotools/src/test/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/NaturalEarthTest.kt b/toolkit/geotools-jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/NaturalEarthTest.kt similarity index 70% rename from toolkit/geotools/src/test/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/NaturalEarthTest.kt rename to toolkit/geotools-jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/NaturalEarthTest.kt index a4c3c9999..d9ef055ee 100644 --- a/toolkit/geotools/src/test/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/NaturalEarthTest.kt +++ b/toolkit/geotools-jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/NaturalEarthTest.kt @@ -1,21 +1,13 @@ package org.jetbrains.letsPlot.toolkit.geotools.jupyter import org.jetbrains.kotlinx.jupyter.testkit.JupyterReplTestCase -import org.jetbrains.kotlinx.jupyter.testkit.ReplProvider import kotlin.test.Test -class NaturalEarthTest : JupyterReplTestCase( - ReplProvider.forLibrariesTesting(listOf("lets-plot-kotlin-geotools")) -) { +class NaturalEarthTest : JupyterReplTestCase() { private val geotoolsDependencies = """ USE { - repositories { - maven("https://repo.osgeo.org/repository/release") - } - dependencies { - implementation("org.geotools:gt-main:32.0") - implementation("org.geotools:gt-shapefile:32.0") + implementation("org.geotools:gt-shapefile:30.0") } } """.trimIndent() @@ -39,7 +31,7 @@ class NaturalEarthTest : JupyterReplTestCase( """.trimIndent() @Test - fun `Plot output in jupyter`() { + fun `natural earth example compilation in jupyter`() { execRendered(geotoolsDependencies) execRendered(naturalEarthMap) } diff --git a/toolkit/geotools/build.gradle.kts b/toolkit/geotools/build.gradle.kts index 763df0d94..eac865da1 100644 --- a/toolkit/geotools/build.gradle.kts +++ b/toolkit/geotools/build.gradle.kts @@ -7,11 +7,6 @@ plugins { kotlin("jvm") `maven-publish` signing - // Add the KSP plugin before the Jupyter API to avoid ksp versions incompatibility. - // May be removed when using further versions of the jupyter api - id("com.google.devtools.ksp") - // Use jupyter API version compatible with java 1.8 - kotlin("jupyter.api") version "0.12.0-139" } val geoToolsVersion = extra["geotools.version"] as String @@ -107,7 +102,3 @@ signing { sign(publishing.publications) } } - -tasks.processJupyterApiResources { - libraryProducers = listOf("org.jetbrains.letsPlot.toolkit.geotools.jupyter.Integration") -} diff --git a/toolkit/geotools/src/main/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/Integration.kt b/toolkit/geotools/src/main/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/Integration.kt deleted file mode 100644 index 2984ccdec..000000000 --- a/toolkit/geotools/src/main/kotlin/org/jetbrains/letsPlot/toolkit/geotools/jupyter/Integration.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.jetbrains.letsPlot.toolkit.geotools.jupyter - -import org.jetbrains.kotlinx.jupyter.api.libraries.JupyterIntegration - -@Suppress("unused") -class Integration: JupyterIntegration() { - override fun Builder.onLoaded() { - import("org.jetbrains.letsPlot.toolkit.geotools.toSpatialDataset") - } -} diff --git a/toolkit/jupyter/build.gradle.kts b/toolkit/jupyter/build.gradle.kts new file mode 100644 index 000000000..4c147f928 --- /dev/null +++ b/toolkit/jupyter/build.gradle.kts @@ -0,0 +1,105 @@ +plugins { + kotlin("jvm") + `maven-publish` + signing + id("com.google.devtools.ksp") + kotlin("jupyter.api") +} + +val letsPlotVersion = extra["letsPlot.version"] as String +val letsPlotKotlinVersion = project.version as String +val kotlinLoggingVersion = extra["kotlinLogging.version"] as String + +dependencies { + // All LP/LPK implementations to be loaded in notebook + implementation(projects.plotApi) + + implementation("org.jetbrains.lets-plot:lets-plot-common:$letsPlotVersion") + implementation("org.jetbrains.lets-plot:platf-awt-jvm:$letsPlotVersion") + implementation("org.jetbrains.lets-plot:lets-plot-image-export:$letsPlotVersion") + implementation("io.github.microutils:kotlin-logging-jvm:$kotlinLoggingVersion") + + implementation(projects.util) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() +} +kotlin { + jvmToolchain(11) +} +tasks.withType().all { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } +} + +tasks.withType().all { + sourceCompatibility = JavaVersion.VERSION_11.toString() + targetCompatibility = JavaVersion.VERSION_11.toString() +} + +tasks.processJupyterApiResources { + libraryProducers = listOf("org.jetbrains.letsPlot.toolkit.jupyter.Integration") +} + +val artifactBaseName = "lets-plot-kotlin-jupyter" +val artifactGroupId = project.group as String +val artifactVersion = project.version as String + +afterEvaluate { + + publishing { + publications { + // Build artifact with all dependencies. + create("letsPlotKotlinJupyter") { + + groupId = artifactGroupId + artifactId = artifactBaseName + version = artifactVersion + + from(components["java"]) + + pom { + name.set("Lets-Plot Kotlin Jupyter Integration") + description.set( + "Lets-Plot Kotlin Integration For Kotlin Jupyter Kernel." + ) + url.set("https://github.com/JetBrains/lets-plot-kotlin") + licenses { + license { + name.set("MIT") + url.set("https://raw.githubusercontent.com/JetBrains/lets-plot-kotlin/master/LICENSE") + } + } + developers { + developer { + id.set("jetbrains") + name.set("JetBrains") + email.set("lets-plot@jetbrains.com") + } + } + scm { + url.set("https://github.com/JetBrains/lets-plot-kotlin") + } + } + } + } + + repositories { + mavenLocal { + val localMavenRepository: String by project + url = uri(localMavenRepository) + } + } + } +} +signing { + if (!(project.version as String).contains("SNAPSHOT")) { + sign(publishing.publications) + } +} + diff --git a/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/Integration.kt b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/Integration.kt similarity index 67% rename from plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/Integration.kt rename to toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/Integration.kt index 2af8b101a..dad4cbe99 100644 --- a/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/Integration.kt +++ b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/Integration.kt @@ -1,7 +1,8 @@ -package org.jetbrains.letsPlot.jupyter +package org.jetbrains.letsPlot.toolkit.jupyter -import org.jetbrains.kotlinx.jupyter.api.* -import org.jetbrains.kotlinx.jupyter.api.libraries.ExecutionHost +import org.jetbrains.kotlinx.jupyter.api.HTML +import org.jetbrains.kotlinx.jupyter.api.Notebook +import org.jetbrains.kotlinx.jupyter.api.declare import org.jetbrains.kotlinx.jupyter.api.libraries.JupyterIntegration import org.jetbrains.kotlinx.jupyter.api.libraries.resources import org.jetbrains.letsPlot.Figure @@ -14,12 +15,20 @@ import org.jetbrains.letsPlot.frontend.NotebookFrontendContext internal class Integration(private val notebook: Notebook, private val options: MutableMap) : JupyterIntegration() { - private val config = JupyterConfig() + // used by kandy-lets-plot + internal val config = JupyterConfig() private lateinit var frontendContext: NotebookFrontendContext + // Take integration options from descriptor by default; + // If used via Kotlin Notebook plugin as a dependency, + // provide defaults (versions from `VersionChecker` + // and empty `isolatedFrame`) + private val api = options["api"] ?: VersionChecker.letsPlotKotlinAPIVersion + private val js = VersionChecker.letsPlotJsVersion + private val isolatedFrame = options["isolatedFrame"] ?: "" - override fun Builder.onLoaded() { + override fun Builder.onLoaded() { import("org.jetbrains.letsPlot.*") import("org.jetbrains.letsPlot.geom.*") import("org.jetbrains.letsPlot.geom.extras.*") @@ -43,15 +52,7 @@ internal class Integration(private val notebook: Notebook, private val options: import("org.jetbrains.letsPlot.intern.toSpec") import("org.jetbrains.letsPlot.spatial.SpatialDataset") - onLoaded { - // Take integration options from descriptor by default; - // If used via Kotlin Notebook plugin as a dependency, - // provide defaults (versions from `VersionChecker` - // and empty `isolatedFrame`) - val api = options["api"] ?: VersionChecker.letsPlotKotlinAPIVersion - val js = options["js"] ?: VersionChecker.letsPlotJsVersion - val isolatedFrame = options["isolatedFrame"] ?: "" val isolatedFrameParam = if (isolatedFrame.isNotEmpty()) isolatedFrame.toBoolean() else null frontendContext = LetsPlot.setupNotebook(js, isolatedFrameParam) { display(HTML(it), null) } LetsPlot.apiVersion = api @@ -71,9 +72,9 @@ internal class Integration(private val notebook: Notebook, private val options: url(scriptUrl(jsVersion)) } } - renderWithHostTemp
{ host, value -> + renderWithHost
{ host, value -> // For cases when Integration is added via Kotlin Notebook project dependency; - // display configure HTML with the first `Figure rendering + // display configure HTML with the first `Figure` rendering if (!firstFigureRendered) { firstFigureRendered = true host.execute { display(HTML(frontendContext.getConfigureHtml()), null) } @@ -82,17 +83,4 @@ internal class Integration(private val notebook: Notebook, private val options: } } - - // copy-pasted from jupyter api; - // it is not possible to use `renderWithHost` directly because it is inline and built with JVM 11 - private inline fun Builder.renderWithHostTemp(noinline renderer: CodeCell.(ExecutionHost, T) -> Any) { - val execution = - ResultHandlerExecution { host, property -> - val currentCell = notebook.currentCell - ?: throw IllegalStateException("Current cell should not be null on renderer invocation") - FieldValue(renderer(currentCell, host, property.value as T), null) - } - addRenderer(SubtypeRendererTypeHandler(T::class, execution)) - } - } diff --git a/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/JupyterConfig.kt b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/JupyterConfig.kt similarity index 90% rename from plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/JupyterConfig.kt rename to toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/JupyterConfig.kt index e73b6fc48..81f8144f5 100644 --- a/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/JupyterConfig.kt +++ b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/JupyterConfig.kt @@ -1,4 +1,4 @@ -package org.jetbrains.letsPlot.jupyter +package org.jetbrains.letsPlot.toolkit.jupyter /** * Lets-Plot config for Jupyter. diff --git a/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/NotebookRenderingContext.kt b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/NotebookRenderingContext.kt similarity index 93% rename from plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/NotebookRenderingContext.kt rename to toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/NotebookRenderingContext.kt index b52b8e4ba..93f3d1520 100644 --- a/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/NotebookRenderingContext.kt +++ b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/NotebookRenderingContext.kt @@ -1,4 +1,4 @@ -package org.jetbrains.letsPlot.jupyter +package org.jetbrains.letsPlot.toolkit.jupyter import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -9,6 +9,8 @@ import org.jetbrains.letsPlot.Figure import org.jetbrains.letsPlot.awt.plot.PlotSvgExport import org.jetbrains.letsPlot.frontend.NotebookFrontendContext import org.jetbrains.letsPlot.intern.toSpec +import org.jetbrains.letsPlot.toolkit.jupyter.util.extendedByJson +import org.jetbrains.letsPlot.toolkit.jupyter.util.serializeSpec import java.util.* internal class NotebookRenderingContext( @@ -22,7 +24,7 @@ internal class NotebookRenderingContext( */ private fun figureToMimeJson(figure: Figure): JsonObject { val spec = figure.toSpec() - val html = frontendContext.getHtml(figure) + val html = frontendContext.getDisplayHtml(figure.toSpec()) return buildJsonObject { put(MimeTypes.HTML, JsonPrimitive(html)) put("application/plot+json", buildJsonObject { diff --git a/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/containerUtil.kt b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/util/containerUtil.kt similarity index 96% rename from plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/containerUtil.kt rename to toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/util/containerUtil.kt index 632e6cfd1..26c497150 100644 --- a/plot-api/src/jvmMain/kotlin/org/jetbrains/letsPlot/jupyter/containerUtil.kt +++ b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/util/containerUtil.kt @@ -1,4 +1,4 @@ -package org.jetbrains.letsPlot.jupyter +package org.jetbrains.letsPlot.toolkit.jupyter.util import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement diff --git a/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/GGBunchTest.kt b/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/GGBunchTest.kt similarity index 80% rename from plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/GGBunchTest.kt rename to toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/GGBunchTest.kt index 12ca64b2b..50be74f25 100644 --- a/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/GGBunchTest.kt +++ b/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/GGBunchTest.kt @@ -1,8 +1,9 @@ -package org.jetbrains.letsPlot.jupyter +package org.jetbrains.letsPlot.toolkit.jupyter +import org.junit.jupiter.api.TestInfo import kotlin.test.Test -internal class GGBunchTest : JupyterTest() { +class GGBunchTest : JupyterTest() { private val bunch = """ val data1 = mapOf( @@ -36,7 +37,7 @@ internal class GGBunchTest : JupyterTest() { fun `compilation of GGBunch in jupyter`() = bunch.checkCompilation() @Test - fun `GGBunch output in jupyter`() { - assertOutput(execRendered(bunch)) + fun `GGBunch output in jupyter`(testInfo: TestInfo) { + assertOutput(execRendered(bunch), testInfo) } } \ No newline at end of file diff --git a/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/JupyterTest.kt b/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/JupyterTest.kt similarity index 78% rename from plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/JupyterTest.kt rename to toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/JupyterTest.kt index fcae62aa9..93f79ca06 100644 --- a/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/JupyterTest.kt +++ b/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/JupyterTest.kt @@ -1,4 +1,4 @@ -package org.jetbrains.letsPlot.jupyter +package org.jetbrains.letsPlot.toolkit.jupyter import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonObject @@ -6,20 +6,16 @@ import org.jetbrains.kotlinx.jupyter.api.Code import org.jetbrains.kotlinx.jupyter.api.MimeTypedResultEx import org.jetbrains.kotlinx.jupyter.testkit.JupyterReplTestCase import org.jetbrains.kotlinx.jupyter.testkit.ReplProvider -import org.junit.Rule -import org.junit.rules.TestName +import org.junit.jupiter.api.TestInfo import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.test.fail -internal abstract class JupyterTest : JupyterReplTestCase( +abstract class JupyterTest : JupyterReplTestCase( ReplProvider.forLibrariesTesting(listOf("lets-plot-kotlin")) ) { - @JvmField - @Rule - val testName: TestName = TestName() private val classLoader = (this::class as Any).javaClass.classLoader @@ -27,14 +23,14 @@ internal abstract class JupyterTest : JupyterReplTestCase( execRendered(this) } - fun assertOutput(actualOutputText: Any?) { + fun assertOutput(actualOutputText: Any?, testInfo: TestInfo) { if (actualOutputText !is MimeTypedResultEx) fail("The output is not MimeTypedResultEx") val outputDataObject = actualOutputText.toJson(JsonObject(mapOf()), null)["data"]?.jsonObject assertNotNull(outputDataObject) assertTrue(outputDataObject.contains("text/html")) assertTrue(outputDataObject.contains("application/plot+json")) - val resourcePath = "jupyter/${testName.methodName}.out" + val resourcePath = "jupyter/${testInfo.testMethod.get().name}.out" val resource = classLoader.getResource(resourcePath) assertNotNull(resource) assertEquals(resource.readText(), outputDataObject["application/plot+json"].toString()) diff --git a/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/PlotTest.kt b/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/PlotTest.kt similarity index 67% rename from plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/PlotTest.kt rename to toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/PlotTest.kt index bcfaecb62..e94b467b2 100644 --- a/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/PlotTest.kt +++ b/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/PlotTest.kt @@ -1,8 +1,9 @@ -package org.jetbrains.letsPlot.jupyter +package org.jetbrains.letsPlot.toolkit.jupyter +import org.junit.jupiter.api.TestInfo import kotlin.test.Test -internal class PlotTest : JupyterTest() { +class PlotTest : JupyterTest() { private val plot = """ val data = mapOf( @@ -19,7 +20,7 @@ internal class PlotTest : JupyterTest() { fun `compilation of Plot in jupyter`() = plot.checkCompilation() @Test - fun `Plot output in jupyter`() { - assertOutput(execRendered(plot)) + fun `Plot output in jupyter`(testInfo: TestInfo) { + assertOutput(execRendered(plot), testInfo) } } \ No newline at end of file diff --git a/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/SubPlotsFigureTest.kt b/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/SubPlotsFigureTest.kt similarity index 71% rename from plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/SubPlotsFigureTest.kt rename to toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/SubPlotsFigureTest.kt index 449396435..f2522fb5e 100644 --- a/plot-api/src/jvmTest/kotlin/org/jetbrains/letsPlot/jupyter/SubPlotsFigureTest.kt +++ b/toolkit/jupyter/src/test/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/SubPlotsFigureTest.kt @@ -1,8 +1,9 @@ -package org.jetbrains.letsPlot.jupyter +package org.jetbrains.letsPlot.toolkit.jupyter +import org.junit.jupiter.api.TestInfo import kotlin.test.Test -internal class SubPlotsFigureTest : JupyterTest() { +class SubPlotsFigureTest : JupyterTest() { private val grid = """ val data1 = mapOf( @@ -25,7 +26,7 @@ internal class SubPlotsFigureTest : JupyterTest() { fun `compilation of SubPlotsFigure in jupyter`() = grid.checkCompilation() @Test - fun `SubPlotsFigure output in jupyter`() { - assertOutput(execRendered(grid)) + fun `SubPlotsFigure output in jupyter`(testInfo: TestInfo) { + assertOutput(execRendered(grid), testInfo) } } \ No newline at end of file diff --git a/plot-api/src/jvmTest/resources/jupyter/GGBunch output in jupyter.out b/toolkit/jupyter/src/test/resources/jupyter/GGBunch output in jupyter.out similarity index 100% rename from plot-api/src/jvmTest/resources/jupyter/GGBunch output in jupyter.out rename to toolkit/jupyter/src/test/resources/jupyter/GGBunch output in jupyter.out diff --git a/plot-api/src/jvmTest/resources/jupyter/Plot output in jupyter.out b/toolkit/jupyter/src/test/resources/jupyter/Plot output in jupyter.out similarity index 100% rename from plot-api/src/jvmTest/resources/jupyter/Plot output in jupyter.out rename to toolkit/jupyter/src/test/resources/jupyter/Plot output in jupyter.out diff --git a/plot-api/src/jvmTest/resources/jupyter/SubPlotsFigure output in jupyter.out b/toolkit/jupyter/src/test/resources/jupyter/SubPlotsFigure output in jupyter.out similarity index 100% rename from plot-api/src/jvmTest/resources/jupyter/SubPlotsFigure output in jupyter.out rename to toolkit/jupyter/src/test/resources/jupyter/SubPlotsFigure output in jupyter.out diff --git a/toolkit/util/build.gradle.kts b/toolkit/util/build.gradle.kts new file mode 100644 index 000000000..6887e481e --- /dev/null +++ b/toolkit/util/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + kotlin("jvm") + `maven-publish` + signing +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + + testImplementation(kotlin("test")) +} + + +val artifactBaseName = "lets-plot-kotlin-util" +val artifactGroupId = project.group as String +val artifactVersion = project.version as String + +afterEvaluate { + publishing { + publications { + create("letsPlotKotlinUtil") { + groupId = artifactGroupId + artifactId = artifactBaseName + version = artifactVersion + + from(components["java"]) + + pom { + name.set("Lets-Plot Kotlin Util") + description.set( + "Lets-Plot Kotlin Util For Spec Serialization." + ) + url.set("https://github.com/JetBrains/lets-plot-kotlin") + licenses { + license { + name.set("MIT") + url.set("https://raw.githubusercontent.com/JetBrains/lets-plot-kotlin/master/LICENSE") + } + } + developers { + developer { + id.set("jetbrains") + name.set("JetBrains") + email.set("lets-plot@jetbrains.com") + } + } + scm { + url.set("https://github.com/JetBrains/lets-plot-kotlin") + } + } + } + } + + repositories { + mavenLocal { + val localMavenRepository: String by project + url = uri(localMavenRepository) + } + } + } +} +signing { + if (!(project.version as String).contains("SNAPSHOT")) { + sign(publishing.publications) + } +} diff --git a/toolkit/util/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/util/serializeSpec.kt b/toolkit/util/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/util/serializeSpec.kt new file mode 100644 index 000000000..889736454 --- /dev/null +++ b/toolkit/util/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/util/serializeSpec.kt @@ -0,0 +1,84 @@ +package org.jetbrains.letsPlot.toolkit.jupyter.util + +import kotlinx.serialization.json.* + +typealias LetsPlotSpec = Map + +fun serializeSpec(spec: LetsPlotSpec): JsonElement { + return serialize(spec) +} + +private fun serializeAny(obj: Any?): JsonElement { + return when (obj) { + null -> JsonNull + is Map<*, *> -> serialize(obj) + is List<*> -> serialize(obj) + is String -> JsonPrimitive(obj) + is Boolean -> JsonPrimitive(obj) + is Number -> JsonPrimitive(obj) + else -> error("Don't know how to parse object [$obj] of class ${obj::class}") + } +} + +private fun serialize(map: Map<*, *>): JsonObject { + return buildJsonObject { + for ((key, value) in map) { + if (key !is String) error("Map key [$key] is of type ${key?.let { it::class }}. Don't know how to serialize it.") + put(key, serializeAny(value)) + } + } +} + +private fun serialize(list: List<*>): JsonArray { + return buildJsonArray { + for (value in list) { + add(serializeAny(value)) + } + } +} + +fun deserializeSpec(json: JsonElement): LetsPlotSpec { + if (json !is JsonObject) error("LetsPlot spec should be a key-value object, but it's $json") + val map = deserializeMap(json) + + for (value in map.values) { + if (value == null) error("LetsPlot spec shouldn't have null values on the top level") + } + + @Suppress("UNCHECKED_CAST") + return map as LetsPlotSpec +} + +private fun deserializeAny(json: JsonElement): Any? { + return when (json) { + is JsonObject -> deserializeMap(json) + is JsonArray -> deserializeList(json) + is JsonPrimitive -> deserializePrimitive(json) + } +} + +private fun deserializePrimitive(json: JsonPrimitive): Any? { + return when { + json is JsonNull -> null + json.isString -> json.content + else -> { + json.booleanOrNull ?: json.doubleOrNull ?: error("Unknown JSON primitive type: [$json]") + } + } +} + +private fun deserializeMap(json: JsonObject): Map { + return buildMap { + for ((key, value) in json) { + put(key, deserializeAny(value)) + } + } +} + +private fun deserializeList(jsonArray: JsonArray): List { + return buildList { + for (el in jsonArray) { + add(deserializeAny(el)) + } + } +} From 7599b9333f22b8c7d3b9536b4726f2873b2adcc5 Mon Sep 17 00:00:00 2001 From: Andrei Date: Sat, 12 Oct 2024 17:59:00 +0400 Subject: [PATCH 3/5] little code clean up --- plot-api/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plot-api/build.gradle.kts b/plot-api/build.gradle.kts index d7aa86a33..af5cf22e3 100644 --- a/plot-api/build.gradle.kts +++ b/plot-api/build.gradle.kts @@ -39,7 +39,7 @@ kotlin { named("jvmMain") { dependencies { - implementation("io.github.microutils:kotlin-logging:$kotlinLoggingVersion") + implementation("io.github.microutils:kotlin-logging:$kotlinLoggingVersion") api("org.jetbrains.lets-plot:lets-plot-common:$letsPlotVersion") // Use "-jvm" variant to work around the issue where LPK JS (IR) artefact becomes dependent on // the "kotlinx-datetime". From bda81dd0e8eea78636d621ea0cba3bf46c781747 Mon Sep 17 00:00:00 2001 From: Andrei Date: Mon, 4 Nov 2024 14:28:33 +0400 Subject: [PATCH 4/5] rename util to json & remove lets-plot mention --- settings.gradle.kts | 4 ++-- toolkit/{util => json}/build.gradle.kts | 6 +++--- .../letsPlot/toolkit/json}/serializeSpec.kt | 19 ++++++++++--------- toolkit/jupyter/build.gradle.kts | 2 +- .../jupyter/NotebookRenderingContext.kt | 6 +++--- .../jupyter/{util => json}/containerUtil.kt | 2 +- 6 files changed, 20 insertions(+), 19 deletions(-) rename toolkit/{util => json}/build.gradle.kts (91%) rename toolkit/{util/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/util => json/src/main/kotlin/org/jetbrains/letsPlot/toolkit/json}/serializeSpec.kt (76%) rename toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/{util => json}/containerUtil.kt (96%) diff --git a/settings.gradle.kts b/settings.gradle.kts index bd38c5c45..eb1371ee1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -47,7 +47,7 @@ include("geotools-batik") include("dokka") include("jupyter") include("geotools-jupyter") -include("util") +include("json") project(":demo-common").projectDir = File("./demo/demo-common") project(":jvm-javafx").projectDir = File("./demo/jvm-javafx") @@ -63,4 +63,4 @@ project(":dokka").projectDir = File("./docs/dokka") project(":jupyter").projectDir = File("./toolkit/jupyter") project(":geotools-jupyter").projectDir = File("./toolkit/geotools-jupyter") -project(":util").projectDir = File("./toolkit/util") \ No newline at end of file +project(":json").projectDir = File("./toolkit/json") \ No newline at end of file diff --git a/toolkit/util/build.gradle.kts b/toolkit/json/build.gradle.kts similarity index 91% rename from toolkit/util/build.gradle.kts rename to toolkit/json/build.gradle.kts index 6887e481e..e092dee1b 100644 --- a/toolkit/util/build.gradle.kts +++ b/toolkit/json/build.gradle.kts @@ -11,14 +11,14 @@ dependencies { } -val artifactBaseName = "lets-plot-kotlin-util" +val artifactBaseName = "lets-plot-kotlin-json" val artifactGroupId = project.group as String val artifactVersion = project.version as String afterEvaluate { publishing { publications { - create("letsPlotKotlinUtil") { + create("letsPlotKotlinJson") { groupId = artifactGroupId artifactId = artifactBaseName version = artifactVersion @@ -28,7 +28,7 @@ afterEvaluate { pom { name.set("Lets-Plot Kotlin Util") description.set( - "Lets-Plot Kotlin Util For Spec Serialization." + "Lets-Plot Kotlin Util For Json Serialization." ) url.set("https://github.com/JetBrains/lets-plot-kotlin") licenses { diff --git a/toolkit/util/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/util/serializeSpec.kt b/toolkit/json/src/main/kotlin/org/jetbrains/letsPlot/toolkit/json/serializeSpec.kt similarity index 76% rename from toolkit/util/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/util/serializeSpec.kt rename to toolkit/json/src/main/kotlin/org/jetbrains/letsPlot/toolkit/json/serializeSpec.kt index 889736454..d5c50e576 100644 --- a/toolkit/util/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/util/serializeSpec.kt +++ b/toolkit/json/src/main/kotlin/org/jetbrains/letsPlot/toolkit/json/serializeSpec.kt @@ -1,11 +1,11 @@ -package org.jetbrains.letsPlot.toolkit.jupyter.util +package org.jetbrains.letsPlot.toolkit.json import kotlinx.serialization.json.* -typealias LetsPlotSpec = Map +typealias JsonMap = Map -fun serializeSpec(spec: LetsPlotSpec): JsonElement { - return serialize(spec) +fun serializeJsonMap(map: JsonMap): JsonElement { + return serialize(map) } private fun serializeAny(obj: Any?): JsonElement { @@ -16,7 +16,7 @@ private fun serializeAny(obj: Any?): JsonElement { is String -> JsonPrimitive(obj) is Boolean -> JsonPrimitive(obj) is Number -> JsonPrimitive(obj) - else -> error("Don't know how to parse object [$obj] of class ${obj::class}") + else -> error("Don't know how to serialize object [$obj] of class ${obj::class}") } } @@ -37,16 +37,17 @@ private fun serialize(list: List<*>): JsonArray { } } -fun deserializeSpec(json: JsonElement): LetsPlotSpec { - if (json !is JsonObject) error("LetsPlot spec should be a key-value object, but it's $json") +fun deserializeJsonMap(json: JsonElement): JsonMap { + if (json !is JsonObject) error("Input json should be a key-value object, but it's $json") val map = deserializeMap(json) + // TODO: add null handling for (value in map.values) { - if (value == null) error("LetsPlot spec shouldn't have null values on the top level") + if (value == null) error("Input json shouldn't have null values on the top level") } @Suppress("UNCHECKED_CAST") - return map as LetsPlotSpec + return map as JsonMap } private fun deserializeAny(json: JsonElement): Any? { diff --git a/toolkit/jupyter/build.gradle.kts b/toolkit/jupyter/build.gradle.kts index 4c147f928..597074793 100644 --- a/toolkit/jupyter/build.gradle.kts +++ b/toolkit/jupyter/build.gradle.kts @@ -19,7 +19,7 @@ dependencies { implementation("org.jetbrains.lets-plot:lets-plot-image-export:$letsPlotVersion") implementation("io.github.microutils:kotlin-logging-jvm:$kotlinLoggingVersion") - implementation(projects.util) + implementation(projects.json) implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") testImplementation(kotlin("test")) diff --git a/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/NotebookRenderingContext.kt b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/NotebookRenderingContext.kt index 93f3d1520..f4d0becc2 100644 --- a/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/NotebookRenderingContext.kt +++ b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/NotebookRenderingContext.kt @@ -9,8 +9,8 @@ import org.jetbrains.letsPlot.Figure import org.jetbrains.letsPlot.awt.plot.PlotSvgExport import org.jetbrains.letsPlot.frontend.NotebookFrontendContext import org.jetbrains.letsPlot.intern.toSpec -import org.jetbrains.letsPlot.toolkit.jupyter.util.extendedByJson -import org.jetbrains.letsPlot.toolkit.jupyter.util.serializeSpec +import org.jetbrains.letsPlot.toolkit.jupyter.json.extendedByJson +import org.jetbrains.letsPlot.toolkit.json.serializeJsonMap import java.util.* internal class NotebookRenderingContext( @@ -29,7 +29,7 @@ internal class NotebookRenderingContext( put(MimeTypes.HTML, JsonPrimitive(html)) put("application/plot+json", buildJsonObject { put("output_type", JsonPrimitive("lets_plot_spec")) - put("output", serializeSpec(spec)) + put("output", serializeJsonMap(spec)) put("apply_color_scheme", JsonPrimitive(config.themeApplied)) put("swing_enabled", JsonPrimitive(config.swingEnabled)) }) diff --git a/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/util/containerUtil.kt b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/json/containerUtil.kt similarity index 96% rename from toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/util/containerUtil.kt rename to toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/json/containerUtil.kt index 26c497150..4ee96c443 100644 --- a/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/util/containerUtil.kt +++ b/toolkit/jupyter/src/main/kotlin/org/jetbrains/letsPlot/toolkit/jupyter/json/containerUtil.kt @@ -1,4 +1,4 @@ -package org.jetbrains.letsPlot.toolkit.jupyter.util +package org.jetbrains.letsPlot.toolkit.jupyter.json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement From 90ea70fef6f322f66e3d9ae8a513e95e62b93150 Mon Sep 17 00:00:00 2001 From: Andrei Date: Mon, 4 Nov 2024 15:17:27 +0400 Subject: [PATCH 5/5] move serialization version --- gradle.properties | 1 + toolkit/json/build.gradle.kts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 581ea57ce..77d2d31da 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,6 +21,7 @@ datetime.version=0.3.2 kotlinLogging.version=2.0.5 slf4j.version=1.7.32 assertj.version=3.26.3 +serialization.version=1.7.3 dokka.version=1.9.20 nexusStaging.version=0.30.0 diff --git a/toolkit/json/build.gradle.kts b/toolkit/json/build.gradle.kts index e092dee1b..e30dc31b7 100644 --- a/toolkit/json/build.gradle.kts +++ b/toolkit/json/build.gradle.kts @@ -4,8 +4,10 @@ plugins { signing } +val serializationVersion = extra["serialization.version"] as String + dependencies { - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion") testImplementation(kotlin("test")) }