diff --git a/.travis.yml b/.travis.yml index 5ab7af0dc..4a3ef33c1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -27,7 +27,7 @@ stages: if: (branch = master AND type = push) OR (tag IS present) jobs: include: - - env: VALIDATE_EXAMPLES=1 # unused from the script, just to know what the job does from the Travis UI + - name: "Validate examples" addons: apt: sources: diff --git a/build.sbt b/build.sbt index 313e13bb4..3f4ab8fca 100644 --- a/build.sbt +++ b/build.sbt @@ -136,6 +136,7 @@ lazy val `scala-interpreter` = project Nil } }, + libraryDependencies += "org.fusesource.jansi" % "jansi" % "1.18", crossVersion := CrossVersion.full, testSettings ) diff --git a/docs/pages/api-ammonite.md b/docs/pages/api-ammonite.md index 0f271e465..18c1f02ba 100644 --- a/docs/pages/api-ammonite.md +++ b/docs/pages/api-ammonite.md @@ -21,27 +21,90 @@ The following instances are available: ### Load dependencies -??? +`interp.load.ivy` accepts one or several +[`coursier.Dependency`](https://github.com/coursier/coursier/blob/ac5a6efa3e13925f0fb1409ea45d6b9a29865deb/modules/coursier/shared/src/main/scala/coursier/package.scala#L19). + +Load simple dependencies +```scala +interp.load.ivy("org.platanios" %% "tensorflow-data" % "0.4.1") +``` + +Load dependencies while adjusting some parameters +```scala +interp.load.ivy( + coursier.Dependency( + module = coursier.Module("org.platanios", "tensorflow_2.12"), + version = "0.4.1", + // replace with linux-gpu-x86_64 on linux with nvidia gpu or with darwin-cpu-x86_64 on macOS + attributes = coursier.Attributes("", "linux-cpu-x86_64") + ) +) +``` + +The dependencies can then be used in the cell right _after_ the one calling +`interp.load.ivy`. + +Note that in the case of simple dependencies, when directly entering code +in a notebook, +the following syntax is preferred +and allows to use the dependency in the current cell rather than the next one: +```scala +import $ivy.`org.platanios::tensorflow-data:0.4.1` +``` ### Load compiler plugins -??? +`interp.load.plugin.ivy` accepts one or several +[`coursier.Dependency`](https://github.com/coursier/coursier/blob/ac5a6efa3e13925f0fb1409ea45d6b9a29865deb/modules/coursier/shared/src/main/scala/coursier/package.scala#L19). + +```scala +interp.load.plugin.ivy("org.spire-math" %% "kind-projector" % "0.9.9") +``` +The plugins can then be used in the cell right _after_ the one calling +`interp.load.plugin.ivy`. + + +Note that when directly entering code in a notebook, the following syntax +is more concise, and allows to use the compiler plugin in the current cell: +```scala +import $plugin.$ivy.`org.spire-math::kind-projector:0.9.9` + +// example of use +trait T[F[_]] +type T2 = T[Either[String, ?]] +``` ### Add repositories -??? +One can add extra +[`coursier.Repository`](https://github.com/coursier/coursier/blob/ac5a6efa3e13925f0fb1409ea45d6b9a29865deb/modules/coursier/shared/src/main/scala/coursier/package.scala#L69) via + +```scala +interp.repositories() ++= Seq(MavenRepository( + "https://nexus.corp.com/content/repositories/releases", + authentication = Some(Authentication("user", "pass")) +)) +``` ### Add exit hooks -??? +```scala +interp.beforeExitHooks += { _ => + // called before the kernel exits +} +``` ### Configure compiler options -??? +```scala +// enable warnings +interp.configureCompiler(_.settings.nowarn.value = false) +``` ## `ReplAPI` [`ReplAPI`](https://github.com/lihaoyi/Ammonite/blob/master/amm/repl/src/main/scala/ammonite/repl/ReplAPI.scala) allows to +- [access the pretty-printer and customize its behavior](#pretty-printer), - [access the latest thrown exception](#latest-thrown-exception), - [access the command history](#command-history), - [request the compiler instance to be re-created](#refresh-compiler-instance), @@ -51,30 +114,63 @@ The following instances are available: - [get the class names and byte code](#byte-code-of-repl-inputs) of the code entered during the current session. +### Pretty-printer + +```scala +class Foo(val x: Int) + +repl.pprinter() = { + val p = repl.pprinter() + p.copy( + additionalHandlers = p.additionalHandlers.orElse { + case f: Foo => + pprint.Tree.Lazy(_ => Iterator(fansi.Color.Yellow(s"foo: ${f.x}").render)) + } + ) +} +``` + ### Latest thrown exception -??? +```scala +repl.lastException // last thrown exception, or null if none were thrown +``` ### Command history -??? +```scala +repl.history // current session history +repl.fullHistory // shared history +``` ### Refresh compiler instance -??? +```scala +repl.newCompiler() +``` ### Get compiler instance -??? +```scala +repl.compiler // has type scala.tools.nsc.Global +``` ### Get current imports -??? +```scala +repl.imports +``` ### Evaluate code -??? +```scala +repl.load("val a = 2") +``` ### Byte code of REPL inputs -??? +```scala +repl.sess.frames.flatMap(_.classloader.newFileDict).toMap +// Map[String, Array[Byte]], keys: class names, values: byte code +``` + diff --git a/docs/pages/api-jupyter.md b/docs/pages/api-jupyter.md index 8f4bd8139..43e43ba43 100644 --- a/docs/pages/api-jupyter.md +++ b/docs/pages/api-jupyter.md @@ -6,13 +6,68 @@ The Almond Jupyter API can be accessed via an instance of [`almond.api.JupyterAP instance is created by almond upon start-up. This instance accessible via the `kernel` variable and in the implicit scope via e.g. `implicitly[almond.api.JupyterAPI]`. +A number of higher level helpers rely on it, and provide [a more convenient API to display objects](#display). + +## High level API + +### Display + +A number of classes under [`almond.display`](https://github.com/almond-sh/almond/tree/master/modules/scala/jupyter-api/src/main/scala/almond/display) +provide an API similar to the +[IPython display module](https://ipython.readthedocs.io/en/7.4.0/api/generated/IPython.display.html). + +Examples: +```scala +// These can be used to display things straightaway +Html("Bold") +``` + +```scala +// A handle can also be retained, to later update or clear things +val handle = Markdown(""" +# title +## section +text +""") + +// can be updated in later cells +// (this updates the previous display) +handle.withContent(""" +# updated title +## new section +_content_ +""").update() + +// can be later cleared +handle.clear() +``` + +### Input + +```scala +// Request input from user +val result = Input().request() +``` + +```scala +val result = Input().withPrompt(">>> ").request() +``` + +```scala +// Request input via password field +val result = Input().withPassword().request() +``` + + ## `JupyterAPI` `almond.api.JupyterAPI` allows to - [request input](#request-input) (password input in particular), +- [exchange comm messages](#comm-messages) with the front-end. - [display data](#display-data) (HTML, text, images, …) in the front-end while a cell is running, - [update a previous display](#updatable-display-data) in the background (while the initial cell is running or not), -- [exchange comm messages](#comm-messages) with the front-end. + +Note that most of its capabilities have more convenient alternatives, see [High level API](#high-level-api). ### Request input @@ -24,6 +79,30 @@ kernel.stdin(prompt = ">> ", password = true) // password input, with custom pro ![](/demo/stdin.gif) +### Comm messages + +[Comm messages](https://jupyter-notebook.readthedocs.io/en/5.7.2/comms.html) are part of the +[Jupyter messaging protocol](https://jupyter-client.readthedocs.io/en/5.2.3/messaging.html). They +allow the exchange of arbitrary messages between code running in the front-end (typically JavaScript code) +and kernels. + +The comm API can be used to receive messages, or send them. + +`kernel.comm.receiver` allows to register a target to receive messages from the front-end, like +```scala +val id = java.util.UUID.randomUUID().toString +kernel.publish.html("Waiting", id) + +kernel.comm.receiver("A") { data => + // received message `data` from front-end + kernel.publish.updateHtml(s"$data", id) +} +``` + +![](/demo/comm-receive.gif) + +TODO Send to client + ### Display data The `publish` field, of type [`almond.interpreter.api.OutputHandler`](https://github.com/almond-sh/almond/blob/master/modules/shared/interpreter-api/src/main/scala/almond/interpreter/api/OutputHandler.scala), has numerous methods to push display data to the front-end. @@ -74,27 +153,3 @@ kernel.publish.updateHtml("Got all items", id) ``` ![](/demo/updatable.gif) - -### Comm messages - -[Comm messages](https://jupyter-notebook.readthedocs.io/en/5.7.2/comms.html) are part of the -[Jupyter messaging protocol](https://jupyter-client.readthedocs.io/en/5.2.3/messaging.html). They -allow the exchange of arbitrary messages between code running in the front-end (typically JavaScript code) -and kernels. - -The comm API can be used to receive messages, or send them. - -`kernel.comm.receiver` allows to register a target to receive messages from the front-end, like -```scala -val id = java.util.UUID.randomUUID().toString -kernel.publish.html("Waiting", id) - -kernel.comm.receiver("A") { data => - // received message `data` from front-end - kernel.publish.updateHtml(s"$data", id) -} -``` - -![](/demo/comm-receive.gif) - -TODO Send to client diff --git a/docs/website/sidebars.json b/docs/website/sidebars.json index 4419dd2e4..c1c1fad83 100644 --- a/docs/website/sidebars.json +++ b/docs/website/sidebars.json @@ -4,7 +4,7 @@ "Try it": ["try-mybinder", "try-docker"], "Installation": ["quick-start-install", "install-options", "install-multiple", "install-versions", "install-other"], "Usage": ["usage-plotting", "usage-spark"], - "User API": ["api", "api-access-instances", "api-ammonite", "api-jupyter"], + "User API": ["api", "api-ammonite", "api-jupyter", "api-access-instances"], "Development": ["dev-from-sources", "dev-custom-kernel", "dev-libraries", "dev-website"] } } diff --git a/examples/displays.ipynb b/examples/displays.ipynb new file mode 100644 index 000000000..0415fe021 --- /dev/null +++ b/examples/displays.ipynb @@ -0,0 +1,164 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "\u001b[32mimport \u001b[39m\u001b[36malmond.display._\u001b[39m" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import almond.display._" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "hello" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "Html(\"hello\")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
" + ], + "text/plain": [ + "Hello 24 / 100\n", + "[####################### ]" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "ProgressBar(24, 100).withLabel(\"Hello {progress} / {total}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "\\begin{eqnarray}\n", + "\\nabla \\times \\vec{\\mathbf{B}} -\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{E}}}{\\partial t} & = \\frac{4\\pi}{c}\\vec{\\mathbf{j}} \\\\\n", + "\\nabla \\cdot \\vec{\\mathbf{E}} & = 4 \\pi \\rho \\\\\n", + "\\nabla \\times \\vec{\\mathbf{E}}\\, +\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{B}}}{\\partial t} & = \\vec{\\mathbf{0}} \\\\\n", + "\\nabla \\cdot \\vec{\\mathbf{B}} & = 0 \n", + "\\end{eqnarray}" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "Latex(\"\"\"\\begin{eqnarray}\n", + "\\nabla \\times \\vec{\\mathbf{B}} -\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{E}}}{\\partial t} & = \\frac{4\\pi}{c}\\vec{\\mathbf{j}} \\\\\n", + "\\nabla \\cdot \\vec{\\mathbf{E}} & = 4 \\pi \\rho \\\\\n", + "\\nabla \\times \\vec{\\mathbf{E}}\\, +\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{B}}}{\\partial t} & = \\vec{\\mathbf{0}} \\\\\n", + "\\nabla \\cdot \\vec{\\mathbf{B}} & = 0 \n", + "\\end{eqnarray}\"\"\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\displaystyle a = b + c$" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "Math(\"a = b + c\")" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": [ + "\n", + "# title\n", + "## subsec\n", + "foo\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "Markdown(\"\"\"\n", + "# title\n", + "## subsec\n", + "foo\n", + "\"\"\")" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Scala (sources)", + "language": "scala", + "name": "scala" + }, + "language_info": { + "codemirror_mode": "text/x-scala", + "file_extension": ".scala", + "mimetype": "text/x-scala", + "name": "scala", + "nbconvert_exporter": "script", + "version": "2.12.8" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/modules/echo/src/main/scala/almond/echo/EchoKernel.scala b/modules/echo/src/main/scala/almond/echo/EchoKernel.scala index 9336e3993..d03bc5482 100644 --- a/modules/echo/src/main/scala/almond/echo/EchoKernel.scala +++ b/modules/echo/src/main/scala/almond/echo/EchoKernel.scala @@ -49,7 +49,7 @@ object EchoKernel extends CaseApp[Options] { val kernelThreads = KernelThreads.create("echo-kernel") val interpreterEc = singleThreadedExecutionContext("echo-interpreter") - log.info("Running kernel") + log.debug("Running kernel") Kernel.create(new EchoInterpreter, interpreterEc, kernelThreads, logCtx) .flatMap(_.runOnConnectionFile(connectionFile, "echo", zeromqThreads)) .unsafeRunSync() diff --git a/modules/scala/almond-rx/src/main/scala/almondrx/package.scala b/modules/scala/almond-rx/src/main/scala/almondrx/package.scala index 0b8ea066f..a2188bed4 100644 --- a/modules/scala/almond-rx/src/main/scala/almondrx/package.scala +++ b/modules/scala/almond-rx/src/main/scala/almondrx/package.scala @@ -1,6 +1,4 @@ -import java.util.UUID - import almond.api.JupyterApi import ammonite.repl.ReplAPI import rx._ @@ -14,6 +12,8 @@ package object almondrx { jupyterApi: JupyterApi ): Unit = { + import jupyterApi.updatableResults._ + implicit val ownerCtx0 = ownerCtx // only really needed when the code wrapper passed to ScalaInterpreter is CodeClassWrapper @@ -24,17 +24,17 @@ package object almondrx { p.copy( additionalHandlers = p.additionalHandlers.orElse { case f: Rx[_] => - val id = "" - val current = replApi.pprinter().tokenize(f.now).mkString - jupyterApi.updatableResults.addVariable(id, current) + val value = updatable( + replApi.pprinter().tokenize(f.now).mkString + ) f.foreach { t => - jupyterApi.updatableResults.updateVariable( - id, + update( + value, replApi.pprinter().tokenize(t).mkString, last = false ) } - pprint.Tree.Literal(id) + pprint.Tree.Literal(value) } ) } diff --git a/modules/scala/almond-spark/src/main/scala/org/apache/spark/sql/almondinternals/NotebookSparkSessionBuilder.scala b/modules/scala/almond-spark/src/main/scala/org/apache/spark/sql/almondinternals/NotebookSparkSessionBuilder.scala index 67afa8a1d..3382b40f1 100644 --- a/modules/scala/almond-spark/src/main/scala/org/apache/spark/sql/almondinternals/NotebookSparkSessionBuilder.scala +++ b/modules/scala/almond-spark/src/main/scala/org/apache/spark/sql/almondinternals/NotebookSparkSessionBuilder.scala @@ -4,7 +4,7 @@ import java.io.File import java.lang.{Boolean => JBoolean} import almond.interpreter.api.{CommHandler, OutputHandler} -import almond.api.helpers.Display.html +import almond.display.Display.html import ammonite.interp.InterpAPI import ammonite.repl.ReplAPI import org.apache.log4j.{Category, Logger, RollingFileAppender} diff --git a/modules/scala/jupyter-api/src/main/scala/almond/api/JupyterApi.scala b/modules/scala/jupyter-api/src/main/scala/almond/api/JupyterApi.scala index b4d36d902..177c5572e 100644 --- a/modules/scala/jupyter-api/src/main/scala/almond/api/JupyterApi.scala +++ b/modules/scala/jupyter-api/src/main/scala/almond/api/JupyterApi.scala @@ -1,5 +1,7 @@ package almond.api +import java.util.UUID + import almond.interpreter.api.{CommHandler, OutputHandler} import jupyter.{Displayer, Displayers} @@ -48,8 +50,25 @@ abstract class JupyterApi { api => object JupyterApi { abstract class UpdatableResults { - def addVariable(k: String, v: String): Unit - def updateVariable(k: String, v: String, last: Boolean): Unit + + @deprecated("Use updatable instead", "0.4.1") + def addVariable(k: String, v: String): Unit = + updatable(k, v) + @deprecated("Use update instead", "0.4.1") + def updateVariable(k: String, v: String, last: Boolean): Unit = + update(k, v, last) + + def updatable(k: String, v: String): Unit = { + // temporary dummy implementation for binary compatibility + } + def updatable(v: String): String = { + val id = UUID.randomUUID().toString + updatable(id, v) + id + } + def update(k: String, v: String, last: Boolean): Unit = { + // temporary dummy implementation for binary compatibility + } } } diff --git a/modules/scala/jupyter-api/src/main/scala/almond/api/helpers/Display.scala b/modules/scala/jupyter-api/src/main/scala/almond/api/helpers/Display.scala index e5b16bbeb..f59cb8378 100644 --- a/modules/scala/jupyter-api/src/main/scala/almond/api/helpers/Display.scala +++ b/modules/scala/jupyter-api/src/main/scala/almond/api/helpers/Display.scala @@ -3,12 +3,13 @@ package almond.api.helpers import java.io.{BufferedInputStream, IOException} import java.net.{URL, URLConnection} import java.nio.file.{Files, Paths} -import java.util.concurrent.atomic.AtomicInteger -import java.util.{Base64, Locale, UUID} +import java.util.Base64 +import almond.display.UpdatableDisplay import almond.interpreter.api.DisplayData.ContentType import almond.interpreter.api.{DisplayData, OutputHandler} +@deprecated("Use almond.display.Data instead", "0.4.1") final class Display(id: String, contentType: String) { def update(content: String)(implicit outputHandler: OutputHandler): Unit = outputHandler.updateDisplay( @@ -22,31 +23,22 @@ final class Display(id: String, contentType: String) { object Display { + @deprecated("Use almond.display.UpdatableDisplay.useRandomIds instead", "0.4.1") def useRandomIds(): Boolean = - sys.props - .get("almond.ids.random") - .forall(s => s == "1" || s.toLowerCase(Locale.ROOT) == "true") - - private val idCounter = new AtomicInteger - private val divCounter = new AtomicInteger + UpdatableDisplay.useRandomIds() + @deprecated("Use almond.display.UpdatableDisplay.generateId instead", "0.4.1") def newId(): String = - if (useRandomIds()) - UUID.randomUUID().toString - else - idCounter.incrementAndGet().toString + UpdatableDisplay.generateId() + @deprecated("Use almond.display.UpdatableDisplay.newDiv instead", "0.4.1") def newDiv(prefix: String = "data-"): String = - prefix + { - if (useRandomIds()) - UUID.randomUUID().toString - else - divCounter.incrementAndGet().toString - } + UpdatableDisplay.generateDiv(prefix) + @deprecated("Use almond.display.Markdown instead", "0.4.1") def markdown(content: String)(implicit outputHandler: OutputHandler): Display = { - val id = newId() + val id = UpdatableDisplay.generateId() outputHandler.display( DisplayData.markdown(content) .withId(id) @@ -54,8 +46,9 @@ object Display { new Display(id, DisplayData.ContentType.markdown) } + @deprecated("Use almond.display.Html instead", "0.4.1") def html(content: String)(implicit outputHandler: OutputHandler): Display = { - val id = newId() + val id = UpdatableDisplay.generateId() outputHandler.display( DisplayData.html(content) .withId(id) @@ -63,8 +56,9 @@ object Display { new Display(id, DisplayData.ContentType.html) } + @deprecated("Use almond.display.Latex instead", "0.4.1") def latex(content: String)(implicit outputHandler: OutputHandler): Display = { - val id = newId() + val id = UpdatableDisplay.generateId() outputHandler.display( DisplayData.latex(content) .withId(id) @@ -72,8 +66,9 @@ object Display { new Display(id, DisplayData.ContentType.latex) } + @deprecated("Use almond.display.Text instead", "0.4.1") def text(content: String)(implicit outputHandler: OutputHandler): Display = { - val id = newId() + val id = UpdatableDisplay.generateId() outputHandler.display( DisplayData.text(content) .withId(id) @@ -81,13 +76,15 @@ object Display { new Display(id, DisplayData.ContentType.text) } + @deprecated("Use almond.display.Javascript instead", "0.4.1") def js(content: String)(implicit outputHandler: OutputHandler): Unit = outputHandler.display( DisplayData.js(content) ) + @deprecated("Use almond.display.Svg instead", "0.4.1") def svg(content: String)(implicit outputHandler: OutputHandler): Display = { - val id = newId() + val id = UpdatableDisplay.generateId() outputHandler.display( DisplayData.svg(content) .withId(id) @@ -95,6 +92,7 @@ object Display { new Display(id, DisplayData.ContentType.svg) } + @deprecated("Use almond.display.Image instead", "0.4.1") object Image { sealed abstract class Format(val contentType: String) extends Product with Serializable @@ -114,7 +112,7 @@ object Display { format: Format, width: Option[String] = None, height: Option[String] = None, - id: String = newId() + id: String = UpdatableDisplay.generateId() )(implicit outputHandler: OutputHandler): Display = { DisplayData( data = Map(format.contentType -> Base64.getEncoder.encodeToString(content)), @@ -130,7 +128,7 @@ object Display { format: Option[Format] = None, width: Option[String] = None, height: Option[String] = None, - id: String = newId() + id: String = UpdatableDisplay.generateId() )(implicit outputHandler: OutputHandler): Display = { val connection = new URL(url).openConnection() connection.setConnectTimeout(5000) @@ -159,7 +157,7 @@ object Display { format: Option[Format] = None, width: Option[String] = None, height: Option[String] = None, - id: String = newId() + id: String = UpdatableDisplay.generateId() )(implicit outputHandler: OutputHandler): Display = { val contentType = format.map(_.contentType).getOrElse(URLConnection.guessContentTypeFromName(path)) if(!imageTypes.contains(contentType)) diff --git a/modules/scala/jupyter-api/src/main/scala/almond/display/Data.scala b/modules/scala/jupyter-api/src/main/scala/almond/display/Data.scala new file mode 100644 index 000000000..910ca1877 --- /dev/null +++ b/modules/scala/jupyter-api/src/main/scala/almond/display/Data.scala @@ -0,0 +1,31 @@ +package almond.display + +final class Data private( + data0: Map[String, String], + metadata0: Map[String, String], + val displayId: String +) extends UpdatableDisplay { + + private def copy( + data: Map[String, String] = data0, + metadata: Map[String, String] = metadata0 + ): Data = + new Data(data, metadata, displayId) + + def withData(data: Map[String, String]): Data = + copy(data = data) + def withMetadata(metadata: Map[String, String]): Data = + copy(metadata = metadata) + + override def metadata(): Map[String, String] = + metadata0 + def data(): Map[String, String] = + data0 +} + +object Data extends { + def apply(data: Map[String, String]): Data = + new Data(data, Map(), UpdatableDisplay.generateId()) + def apply(data: (String, String)*): Data = + new Data(data.toMap, Map(), UpdatableDisplay.generateId()) +} diff --git a/modules/scala/jupyter-api/src/main/scala/almond/display/Display.scala b/modules/scala/jupyter-api/src/main/scala/almond/display/Display.scala new file mode 100644 index 000000000..18517528c --- /dev/null +++ b/modules/scala/jupyter-api/src/main/scala/almond/display/Display.scala @@ -0,0 +1,76 @@ +package almond.display + +import java.io.File +import java.net.URL +import java.nio.file.Path +import java.util.{Map => JMap} + +import almond.interpreter.api.{DisplayData, OutputHandler} +import jupyter.{Displayer, Displayers} + +import scala.collection.JavaConverters._ + +trait Display { + def data(): Map[String, String] + def metadata(): Map[String, String] = Map() + def displayData(): DisplayData = + DisplayData(data(), metadata = metadata()) + + def display()(implicit output: OutputHandler): Unit = + output.display(displayData()) + + // registering things with jvm-repr just in case + Display.registered +} + +object Display { + + private lazy val registered: Unit = { + Displayers.register( + classOf[Display], + new Displayer[Display] { + def display(d: Display): JMap[String, String] = + d.data().asJava + } + ) + } + + + def markdown(content: String)(implicit output: OutputHandler): Unit = + Markdown(content).display() + def html(content: String)(implicit output: OutputHandler): Unit = + Html(content).display() + def latex(content: String)(implicit output: OutputHandler): Unit = + Latex(content).display() + def text(content: String)(implicit output: OutputHandler): Unit = + Text(content).display() + + def js(content: String)(implicit output: OutputHandler): Unit = + Javascript(content).display() + + def svg(content: String)(implicit output: OutputHandler): Unit = + Svg(content).display() + + + trait Builder[C, T] { + + protected def build(contentOrUrl: Either[URL, C]): T + + def apply(content: C): T = + build(Right(content)) + + def from(url: String): T = + build(Left(new URL(url))) + def from(url: URL): T = + build(Left(url)) + + def fromFile(file: File): T = + build(Left(file.toURI.toURL)) + def fromFile(path: Path): T = + build(Left(path.toUri.toURL)) + def fromFile(path: String): T = + build(Left(new File(path).toURI.toURL)) + } + + +} diff --git a/modules/scala/jupyter-api/src/main/scala/almond/display/FileLink.scala b/modules/scala/jupyter-api/src/main/scala/almond/display/FileLink.scala new file mode 100644 index 000000000..0fb7a4fc5 --- /dev/null +++ b/modules/scala/jupyter-api/src/main/scala/almond/display/FileLink.scala @@ -0,0 +1,60 @@ +package almond.display + +import java.net.URI + +final class FileLink private( + val link: String, + val beforeHtml: String, + val afterHtml: String, + val urlPrefix: String, + val displayId: String +) extends UpdatableDisplay { + + private def copy( + link: String = link, + beforeHtml: String = beforeHtml, + afterHtml: String = afterHtml, + urlPrefix: String = urlPrefix + ): FileLink = + new FileLink(link, beforeHtml, afterHtml, urlPrefix, displayId) + + def withLink(link: String): FileLink = + copy(link = link) + def withBeforeHtml(beforeHtml: String): FileLink = + copy(beforeHtml = beforeHtml) + def withAfterHtml(afterHtml: String): FileLink = + copy(afterHtml = afterHtml) + def withUrlPrefix(urlPrefix: String): FileLink = + copy(urlPrefix = urlPrefix) + + private def html: String = { + val link0 = new URI(urlPrefix + link).toASCIIString + beforeHtml + + s"""${FileLink.escapeHTML(link)}""" + + afterHtml + } + + def data(): Map[String, String] = + Map(Html.mimeType -> html) + +} + +object FileLink extends { + def apply(link: String): FileLink = + new FileLink(link, "", "
", "", UpdatableDisplay.generateId()) + + // https://stackoverflow.com/a/25228492/3714539 + private def escapeHTML(s: String): String = { + val out = new StringBuilder(java.lang.Math.max(16, s.length)) + for (i <- 0 until s.length) { + val c = s.charAt(i) + if (c > 127 || c == '"' || c == '<' || c == '>' || c == '&') { + out.append("&#") + out.append(c.toInt) + out.append(';') + } else + out.append(c) + } + out.toString + } +} diff --git a/modules/scala/jupyter-api/src/main/scala/almond/display/Html.scala b/modules/scala/jupyter-api/src/main/scala/almond/display/Html.scala new file mode 100644 index 000000000..2a49ad93b --- /dev/null +++ b/modules/scala/jupyter-api/src/main/scala/almond/display/Html.scala @@ -0,0 +1,32 @@ +package almond.display + +import java.net.URL + +final class Html private( + val contentOrUrl: Either[URL, String], + val displayId: String +) extends TextDisplay { + + private def copy( + contentOrUrl: Either[URL, String] = contentOrUrl + ): Html = + new Html(contentOrUrl, displayId) + + def withContent(code: String): Html = + copy(contentOrUrl = Right(code)) + def withUrl(url: String): Html = + copy(contentOrUrl = Left(new URL(url))) + def withUrl(url: URL): Html = + copy(contentOrUrl = Left(url)) + + def data(): Map[String, String] = + Map(Html.mimeType -> finalContent) + +} + +object Html extends TextDisplay.Builder[Html] { + protected def build(contentOrUrl: Either[URL, String]): Html = + new Html(contentOrUrl, UpdatableDisplay.generateId()) + + def mimeType: String = "text/html" +} diff --git a/modules/scala/jupyter-api/src/main/scala/almond/display/IFrame.scala b/modules/scala/jupyter-api/src/main/scala/almond/display/IFrame.scala new file mode 100644 index 000000000..5ffebf34b --- /dev/null +++ b/modules/scala/jupyter-api/src/main/scala/almond/display/IFrame.scala @@ -0,0 +1,68 @@ +package almond.display + +import java.net.URLEncoder + +final class IFrame private( + val src: String, + val width: Option[String], + val height: Option[String], + val params: Seq[(String, String)], + val displayId: String +) extends UpdatableDisplay { + + private def copy( + src: String = src, + width: Option[String] = width, + height: Option[String] = height, + params: Seq[(String, String)] = params + ): IFrame = + new IFrame(src, width, height, params, displayId) + + def withSrc(src: String): IFrame = + copy(src = src) + def withWidth(width: Int): IFrame = + copy(width = Some(width.toString)) + def withWidth(width: String): IFrame = + copy(width = Some(width)) + def withWidth(widthOpt: Option[String]): IFrame = + copy(width = widthOpt) + def withHeight(height: Int): IFrame = + copy(height = Some(height.toString)) + def withHeight(height: String): IFrame = + copy(height = Some(height)) + def withHeight(heightOpt: Option[String]): IFrame = + copy(height = heightOpt) + + private def html: String = { + val widthPart = width.fold("")(w => s"""width="$w"""") + val heightPart = height.fold("")(h => s"""height="$h"""") + val url = src + { + if (params.isEmpty) "" + else { + def encode(s: String): String = + URLEncoder.encode(s, "UTF-8") + .replaceAll("\\+", "%20") + params + .map { case (k, v) => s"${encode(k)}=${encode(v)}" } + .mkString("?", "&", "") + } + } + // FIXME Escaping of width / height + s""" + """.stripMargin + } + + def data(): Map[String, String] = + Map(Html.mimeType -> html) + +} + +object IFrame extends { + def apply(src: String): IFrame = + new IFrame(src, None, None, Nil, UpdatableDisplay.generateId()) +} diff --git a/modules/scala/jupyter-api/src/main/scala/almond/display/Image.scala b/modules/scala/jupyter-api/src/main/scala/almond/display/Image.scala new file mode 100644 index 000000000..40763f7b1 --- /dev/null +++ b/modules/scala/jupyter-api/src/main/scala/almond/display/Image.scala @@ -0,0 +1,157 @@ +package almond.display + +import java.io.{ByteArrayInputStream, IOException} +import java.net.{HttpURLConnection, URL, URLConnection} +import java.util.Base64 + +import scala.util.Try + +final class Image private ( + val width: Option[String], + val height: Option[String], + val format: Option[Image.Format], + byteArrayOrUrl: Either[URL, Array[Byte]], + val embed: Boolean, + val displayId: String +) extends UpdatableDisplay { + + def byteArrayOpt: Option[Array[Byte]] = + byteArrayOrUrl.right.toOption + def urlOpt: Option[URL] = + byteArrayOrUrl.left.toOption + + private def copy( + width: Option[String] = width, + height: Option[String] = height, + format: Option[Image.Format] = format, + byteArrayOrUrl: Either[URL, Array[Byte]] = byteArrayOrUrl, + embed: Boolean = embed + ): Image = + new Image( + width, + height, + format, + byteArrayOrUrl, + embed, + displayId + ) + + def withByteArray(data: Array[Byte]): Image = + copy(byteArrayOrUrl = Right(data)) + def withUrl(url: URL): Image = + copy(byteArrayOrUrl = Left(url)) + def withUrl(url: String): Image = + copy(byteArrayOrUrl = Left(new URL(url))) + def withHeight(height: Int): Image = + copy(height = Some(height.toString)) + def withHeight(heightOpt: Option[String]): Image = + copy(height = heightOpt) + def withWidth(width: Int): Image = + copy(width = Some(width.toString)) + def withWidth(widthOpt: Option[String]): Image = + copy(width = widthOpt) + def withFormat(format: Image.Format): Image = + copy(format = Some(format)) + def withFormat(formatOpt: Option[Image.Format]): Image = + copy(format = formatOpt) + def withEmbed(): Image = + copy(embed = true) + def withEmbed(embed: Boolean): Image = + copy(embed = embed) + + override def metadata(): Map[String, String] = + Map() ++ + width.map("width" -> _) ++ + height.map("height" -> _) + + def data(): Map[String, String] = + byteArrayOrUrl match { + case Left(url) => + + if (embed) { + val (contentTypeOpt, b) = Image.urlContent(url) + val contentType = format + .map(_.contentType) + .orElse(contentTypeOpt) + .orElse(Option(URLConnection.guessContentTypeFromStream(new ByteArrayInputStream(b)))) + .getOrElse { + throw new Exception(s"Cannot detect format or unrecognizable format for image at $url") + } + + if (!Image.imageTypes.contains(contentType)) + throw new IOException("Unknown or unsupported content type: " + contentType) + + val b0 = Base64.getEncoder.encodeToString(b) + Map(contentType -> b0) + } else { + val attrs = metadata().map { case (k, v) => s"$k=$v" }.mkString(" ") + Map(Html.mimeType -> s"") + } + + case Right(b) => + + val contentType = format + .map(_.contentType) + .orElse(Option(URLConnection.guessContentTypeFromStream(new ByteArrayInputStream(b)))) + .getOrElse { + throw new Exception("Cannot detect image format or unrecognizable image format") + } + + if (!Image.imageTypes.contains(contentType)) + throw new IOException("Unknown or unsupported content type: " + contentType) + + val b0 = Base64.getEncoder.encodeToString(b) + Map(contentType -> b0) + } + +} + +object Image extends Display.Builder[Array[Byte], Image] { + + protected def build(contentOrUrl: Either[URL, Array[Byte]]): Image = + new Image( + None, + None, + None, + contentOrUrl, + embed = contentOrUrl.left.exists(_.getProtocol == "file"), + UpdatableDisplay.generateId() + ) + + sealed abstract class Format(val contentType: String) extends Product with Serializable + case object JPG extends Format("image/jpeg") + case object PNG extends Format("image/png") + case object GIF extends Format("image/gif") + + private val imageTypes = Set(JPG, PNG, GIF).map(_.contentType) + + + private def urlContent(url: URL): (Option[String], Array[Byte]) = { + + var conn: URLConnection = null + val (rawContent, contentTypeOpt) = try { + conn = url.openConnection() + conn.setConnectTimeout(5000) // allow users to tweak that? + val b = TextDisplay.readFully(conn.getInputStream) + val contentTypeOpt0 = conn match { + case conn0: HttpURLConnection => + Option(conn0.getContentType) + case _ => + None + } + (b, contentTypeOpt0) + } finally { + if (conn != null) { + Try(conn.getInputStream.close()) + conn match { + case conn0: HttpURLConnection => + Try(conn0.getErrorStream.close()) + Try(conn0.disconnect()) + case _ => + } + } + } + + (contentTypeOpt, rawContent) + } +} diff --git a/modules/scala/jupyter-api/src/main/scala/almond/display/Javascript.scala b/modules/scala/jupyter-api/src/main/scala/almond/display/Javascript.scala new file mode 100644 index 000000000..c72f784ac --- /dev/null +++ b/modules/scala/jupyter-api/src/main/scala/almond/display/Javascript.scala @@ -0,0 +1,32 @@ +package almond.display + +import java.net.URL + +final class Javascript private( + val contentOrUrl: Either[URL, String], + val displayId: String +) extends TextDisplay { + + private def copy( + contentOrUrl: Either[URL, String] = contentOrUrl + ): Javascript = + new Javascript(contentOrUrl, displayId) + + def withContent(code: String): Javascript = + copy(contentOrUrl = Right(code)) + def withUrl(url: String): Javascript = + copy(contentOrUrl = Left(new URL(url))) + def withUrl(url: URL): Javascript = + copy(contentOrUrl = Left(url)) + + def data(): Map[String, String] = + Map(Javascript.mimeType -> finalContent) + +} + +object Javascript extends TextDisplay.Builder[Javascript] { + protected def build(contentOrUrl: Either[URL, String]): Javascript = + new Javascript(contentOrUrl, UpdatableDisplay.generateId()) + + def mimeType = "application/javascript" +} diff --git a/modules/scala/jupyter-api/src/main/scala/almond/display/Json.scala b/modules/scala/jupyter-api/src/main/scala/almond/display/Json.scala new file mode 100644 index 000000000..9d6b435ac --- /dev/null +++ b/modules/scala/jupyter-api/src/main/scala/almond/display/Json.scala @@ -0,0 +1,46 @@ +package almond.display + +import java.net.URL + +final class Json private( + val contentOrUrl: Either[URL, String], + // FIXME This may not be the right terminology (see https://en.wikipedia.org/wiki/Media_type). + // This could also be generalized to the other Display classes. + val vendorPart: Option[String], + val displayId: String +) extends TextDisplay { + + private def copy( + contentOrUrl: Either[URL, String] = contentOrUrl, + vendorPart: Option[String] = vendorPart + ): Json = + new Json(contentOrUrl, vendorPart, displayId) + + def withContent(code: String): Json = + copy(contentOrUrl = Right(code)) + def withUrl(url: String): Json = + copy(contentOrUrl = Left(new URL(url))) + def withUrl(url: URL): Json = + copy(contentOrUrl = Left(url)) + def withSubType(subType: String): Json = + copy(vendorPart = Some(subType)) + def withVendorPart(vendorPartOpt: Option[String]): Json = + copy(vendorPart = vendorPartOpt) + + def mimeType: String = { + val vendorPart0 = vendorPart.fold("")(_ + "+") + Json.mimeType(vendorPart0) + } + + def data(): Map[String, String] = + Map(mimeType -> finalContent) + +} + +object Json extends TextDisplay.Builder[Json] { + protected def build(contentOrUrl: Either[URL, String]): Json = + new Json(contentOrUrl, None, UpdatableDisplay.generateId()) + + def mimeType = "application/json" + def mimeType(vendorPart: String) = s"application/${vendorPart}json" +} diff --git a/modules/scala/jupyter-api/src/main/scala/almond/display/Latex.scala b/modules/scala/jupyter-api/src/main/scala/almond/display/Latex.scala new file mode 100644 index 000000000..18fc25c1b --- /dev/null +++ b/modules/scala/jupyter-api/src/main/scala/almond/display/Latex.scala @@ -0,0 +1,32 @@ +package almond.display + +import java.net.URL + +final class Latex private( + val contentOrUrl: Either[URL, String], + val displayId: String +) extends TextDisplay { + + private def copy( + contentOrUrl: Either[URL, String] = contentOrUrl + ): Latex = + new Latex(contentOrUrl, displayId) + + def withContent(code: String): Latex = + copy(contentOrUrl = Right(code)) + def withUrl(url: String): Latex = + copy(contentOrUrl = Left(new URL(url))) + def withUrl(url: URL): Latex = + copy(contentOrUrl = Left(url)) + + def data(): Map[String, String] = + Map(Latex.mimeType -> finalContent) + +} + +object Latex extends TextDisplay.Builder[Latex] { + protected def build(contentOrUrl: Either[URL, String]): Latex = + new Latex(contentOrUrl, UpdatableDisplay.generateId()) + + def mimeType = "text/latex" +} diff --git a/modules/scala/jupyter-api/src/main/scala/almond/display/Markdown.scala b/modules/scala/jupyter-api/src/main/scala/almond/display/Markdown.scala new file mode 100644 index 000000000..7c1b6596e --- /dev/null +++ b/modules/scala/jupyter-api/src/main/scala/almond/display/Markdown.scala @@ -0,0 +1,32 @@ +package almond.display + +import java.net.URL + +final class Markdown private( + val contentOrUrl: Either[URL, String], + val displayId: String +) extends TextDisplay { + + private def copy( + contentOrUrl: Either[URL, String] = contentOrUrl + ): Markdown = + new Markdown(contentOrUrl, displayId) + + def withContent(code: String): Markdown = + copy(contentOrUrl = Right(code)) + def withUrl(url: String): Markdown = + copy(contentOrUrl = Left(new URL(url))) + def withUrl(url: URL): Markdown = + copy(contentOrUrl = Left(url)) + + def data(): Map[String, String] = + Map(Markdown.mimeType -> finalContent) + +} + +object Markdown extends TextDisplay.Builder[Markdown] { + protected def build(contentOrUrl: Either[URL, String]): Markdown = + new Markdown(contentOrUrl, UpdatableDisplay.generateId()) + + def mimeType = "text/markdown" +} diff --git a/modules/scala/jupyter-api/src/main/scala/almond/display/Math.scala b/modules/scala/jupyter-api/src/main/scala/almond/display/Math.scala new file mode 100644 index 000000000..4ba042608 --- /dev/null +++ b/modules/scala/jupyter-api/src/main/scala/almond/display/Math.scala @@ -0,0 +1,37 @@ +package almond.display + +import java.net.URL + +final class Math private( + val contentOrUrl: Either[URL, String], + val displayId: String +) extends TextDisplay { + + private def copy( + contentOrUrl: Either[URL, String] = contentOrUrl + ): Math = + new Math(contentOrUrl, displayId) + + def withContent(code: String): Math = + copy(contentOrUrl = Right(code)) + def withUrl(url: String): Math = + copy(contentOrUrl = Left(new URL(url))) + def withUrl(url: URL): Math = + copy(contentOrUrl = Left(url)) + + private def latex: String = { + val finalContent0 = finalContent + .dropRight(finalContent.reverseIterator.takeWhile(_ == '$').length) + .dropWhile(_ == '$') + "$\\displaystyle " + finalContent0 + "$" + } + + def data(): Map[String, String] = + Map(Latex.mimeType -> latex) + +} + +object Math extends TextDisplay.Builder[Math] { + protected def build(contentOrUrl: Either[URL, String]): Math = + new Math(contentOrUrl, UpdatableDisplay.generateId()) +} diff --git a/modules/scala/jupyter-api/src/main/scala/almond/display/ProgressBar.scala b/modules/scala/jupyter-api/src/main/scala/almond/display/ProgressBar.scala new file mode 100644 index 000000000..8884354b7 --- /dev/null +++ b/modules/scala/jupyter-api/src/main/scala/almond/display/ProgressBar.scala @@ -0,0 +1,90 @@ +package almond.display + +final class ProgressBar private( + val label: Option[String], + val total: Int, + val progress: Option[Int], + val htmlWidth: String, + val textWidth: Int, + val displayId: String +) extends UpdatableDisplay { + + private def copy( + label: Option[String] = label, + total: Int = total, + progress: Option[Int] = progress, + htmlWidth: String = htmlWidth, + textWidth: Int = textWidth + ): ProgressBar = + new ProgressBar(label, total, progress, htmlWidth, textWidth, displayId) + + def withLabel(label: String): ProgressBar = + copy(label = Some(label)) + def withLabel(labelOpt: Option[String]): ProgressBar = + copy(label = labelOpt) + def withTotal(total: Int): ProgressBar = + copy(total = total) + def withProgress(progress: Int): ProgressBar = + copy(progress = Some(progress)) + def withProgress(progressOpt: Option[Int]): ProgressBar = + copy(progress = progressOpt) + def withHtmlWidth(width: String): ProgressBar = + copy(htmlWidth = width) + def withTextWidth(textWidth: Int): ProgressBar = + copy(textWidth = textWidth) + + private def finalLabelOpt: Option[String] = + label + .filter(lab => !lab.contains("{progress}") || progress.nonEmpty) + .map { lab => + lab + .replace("{progress}", progress.fold("")(_.toString)) + .replace("{total}", total.toString) + } + + private def html: String = { + val widthPart = s"style='width:$htmlWidth'" + val progressPart = progress.fold("")(v => s"value='$v'") + val idPart = finalLabelOpt.fold("")(_ => s"id='progress-$displayId'") + + // FIXME Escape label? + val labelPart = finalLabelOpt.fold("")(l => s"
") + val bar = s"""""" + + labelPart + bar + } + + private def text: String = { + + val bar = progress match { + case None => + val ellipsis = (total - 2).min(3).max(0) + val remaining = (total - 2 - ellipsis).max(0) + val remainingLeft = remaining / 2 + val remainingRight = remaining - remainingLeft + "[" + (" " * remainingLeft) + ("." * ellipsis) + (" " * remainingRight) + "]" + case Some(p) => + val remaining = (total - 2).max(0) + val filled = ((p * remaining) / total).max(0) + val empty = (remaining - filled).max(0) + "[" + ("#" * filled) + (" " * empty) + "]" + } + + finalLabelOpt.fold("")(_ + System.lineSeparator()) + + bar + } + + def data(): Map[String, String] = + Map( + Text.mimeType -> text, + Html.mimeType -> html + ) + +} + +object ProgressBar extends { + def apply(total: Int): ProgressBar = + new ProgressBar(None, total, None, "60ex", 60, UpdatableDisplay.generateId()) + def apply(progress: Int, total: Int): ProgressBar = + new ProgressBar(None, total, Some(progress), "60ex", 60, UpdatableDisplay.generateId()) +} diff --git a/modules/scala/jupyter-api/src/main/scala/almond/display/Svg.scala b/modules/scala/jupyter-api/src/main/scala/almond/display/Svg.scala new file mode 100644 index 000000000..32519749b --- /dev/null +++ b/modules/scala/jupyter-api/src/main/scala/almond/display/Svg.scala @@ -0,0 +1,32 @@ +package almond.display + +import java.net.URL + +final class Svg private( + val contentOrUrl: Either[URL, String], + val displayId: String +) extends TextDisplay { + + private def copy( + contentOrUrl: Either[URL, String] = contentOrUrl + ): Svg = + new Svg(contentOrUrl, displayId) + + def withContent(code: String): Svg = + copy(contentOrUrl = Right(code)) + def withUrl(url: String): Svg = + copy(contentOrUrl = Left(new URL(url))) + def withUrl(url: URL): Svg = + copy(contentOrUrl = Left(url)) + + def data(): Map[String, String] = + Map(Svg.mimeType -> finalContent) + +} + +object Svg extends TextDisplay.Builder[Svg] { + protected def build(contentOrUrl: Either[URL, String]): Svg = + new Svg(contentOrUrl, UpdatableDisplay.generateId()) + + def mimeType = "image/svg+xml" +} diff --git a/modules/scala/jupyter-api/src/main/scala/almond/display/Text.scala b/modules/scala/jupyter-api/src/main/scala/almond/display/Text.scala new file mode 100644 index 000000000..cb94131ce --- /dev/null +++ b/modules/scala/jupyter-api/src/main/scala/almond/display/Text.scala @@ -0,0 +1,32 @@ +package almond.display + +import java.net.URL + +final class Text private( + val contentOrUrl: Either[URL, String], + val displayId: String +) extends TextDisplay { + + private def copy( + contentOrUrl: Either[URL, String] = contentOrUrl + ): Text = + new Text(contentOrUrl, displayId) + + def withContent(code: String): Text = + copy(contentOrUrl = Right(code)) + def withUrl(url: String): Text = + copy(contentOrUrl = Left(new URL(url))) + def withUrl(url: URL): Text = + copy(contentOrUrl = Left(url)) + + def data(): Map[String, String] = + Map(Text.mimeType -> finalContent) + +} + +object Text extends TextDisplay.Builder[Text] { + protected def build(contentOrUrl: Either[URL, String]): Text = + new Text(contentOrUrl, UpdatableDisplay.generateId()) + + def mimeType = "text/plain" +} diff --git a/modules/scala/jupyter-api/src/main/scala/almond/display/TextDisplay.scala b/modules/scala/jupyter-api/src/main/scala/almond/display/TextDisplay.scala new file mode 100644 index 000000000..d0f4daa36 --- /dev/null +++ b/modules/scala/jupyter-api/src/main/scala/almond/display/TextDisplay.scala @@ -0,0 +1,83 @@ +package almond.display + +import java.io.{ByteArrayOutputStream, InputStream} +import java.net.{HttpURLConnection, URL, URLConnection} +import java.nio.charset.{Charset, StandardCharsets} + +import scala.util.Try + +abstract class TextDisplay extends UpdatableDisplay { + + def contentOrUrl: Either[URL, String] + + def content: Option[String] = contentOrUrl.right.toOption + def url: Option[URL] = contentOrUrl.left.toOption + + def finalContent: String = + contentOrUrl match { + case Left(url) => + TextDisplay.urlContent(url) + case Right(c) => c + } + + def withContent(code: String): UpdatableDisplay + def withUrl(url: String): UpdatableDisplay + +} + +object TextDisplay { + + type Builder[T] = Display.Builder[String, T] + + private[almond] def readFully(is: InputStream): Array[Byte] = { + + val buffer = new ByteArrayOutputStream + val data = Array.ofDim[Byte](16384) + + var nRead = 0 + while ( { + nRead = is.read(data, 0, data.length) + nRead != -1 + }) + buffer.write(data, 0, nRead) + + buffer.flush() + buffer.toByteArray + } + + def urlContent(url: URL): String = { + + var conn: URLConnection = null + val (rawContent, charsetOpt) = try { + conn = url.openConnection() + conn.setConnectTimeout(5000) // allow users to tweak that? + val b = readFully(conn.getInputStream) + val charsetOpt0 = conn match { + case conn0: HttpURLConnection => + conn0 + .getContentType + .split(';') + .map(_.trim) + .find(_.startsWith("charset=")) + .map(_.stripPrefix("charset=")) + .filter(Charset.isSupported) + .map(Charset.forName) + case _ => + None + } + (b, charsetOpt0) + } finally { + if (conn != null) { + Try(conn.getInputStream.close()) + conn match { + case conn0: HttpURLConnection => + Try(conn0.getErrorStream.close()) + Try(conn0.disconnect()) + case _ => + } + } + } + + new String(rawContent, charsetOpt.getOrElse(StandardCharsets.UTF_8)) + } +} diff --git a/modules/scala/jupyter-api/src/main/scala/almond/display/UpdatableDisplay.scala b/modules/scala/jupyter-api/src/main/scala/almond/display/UpdatableDisplay.scala new file mode 100644 index 000000000..d558416a2 --- /dev/null +++ b/modules/scala/jupyter-api/src/main/scala/almond/display/UpdatableDisplay.scala @@ -0,0 +1,48 @@ +package almond.display + +import java.util.{Locale, UUID} +import java.util.concurrent.atomic.AtomicInteger + +import almond.interpreter.api.{DisplayData, OutputHandler} + +trait UpdatableDisplay extends Display { + def displayId: String + override def displayData(): DisplayData = + DisplayData(data(), metadata = metadata(), idOpt = Some(displayId)) + protected def emptyDisplayData(): DisplayData = { + val data = displayData() + data.copy(data = data.data.mapValues(_ => "").toMap) + } + + def update()(implicit output: OutputHandler): Unit = + output.updateDisplay(displayData()) + + def clear()(implicit output: OutputHandler): Unit = + output.updateDisplay(emptyDisplayData()) +} + +object UpdatableDisplay { + + def useRandomIds(): Boolean = + sys.props + .get("almond.ids.random") + .forall(s => s == "1" || s.toLowerCase(Locale.ROOT) == "true") + + private val idCounter = new AtomicInteger + private val divCounter = new AtomicInteger + + def generateId(): String = + if (useRandomIds()) + UUID.randomUUID().toString + else + idCounter.incrementAndGet().toString + + def generateDiv(prefix: String = "data-"): String = + prefix + { + if (useRandomIds()) + UUID.randomUUID().toString + else + divCounter.incrementAndGet().toString + } + +} diff --git a/modules/scala/jupyter-api/src/main/scala/almond/input/Input.scala b/modules/scala/jupyter-api/src/main/scala/almond/input/Input.scala new file mode 100644 index 000000000..eb5e80456 --- /dev/null +++ b/modules/scala/jupyter-api/src/main/scala/almond/input/Input.scala @@ -0,0 +1,38 @@ +package almond.input + +import almond.api.JupyterApi + +final class Input private ( + val prompt: String, + val password: Boolean +) { + + private def copy( + prompt: String = prompt, + password: Boolean = password + ): Input = + new Input(prompt, password) + + def withPrompt(prompt: String): Input = + copy(prompt = prompt) + def withPassword(password: Boolean): Input = + copy(password = password) + def withPassword(): Input = + copy(password = true) + + // TODO Also allow to return result via a future? + def request()(implicit api: JupyterApi): String = + api.stdin(prompt, password) + +} + +object Input { + def apply(): Input = + new Input("", password = false) + def apply(prompt: String): Input = + new Input(prompt, password = false) + def password(): Input = + new Input("", password = true) + def password(prompt: String): Input = + new Input(prompt, password = true) +} diff --git a/modules/scala/scala-interpreter/src/main/java/almond/internals/HtmlAnsiOutputStream.java b/modules/scala/scala-interpreter/src/main/java/almond/internals/HtmlAnsiOutputStream.java new file mode 100644 index 000000000..eff25abd0 --- /dev/null +++ b/modules/scala/scala-interpreter/src/main/java/almond/internals/HtmlAnsiOutputStream.java @@ -0,0 +1,128 @@ + +// adapted from https://github.com/fusesource/jansi/blob/70ff98d5cbd5fb005d8a44ed31050388b256f9c6/jansi/src/main/java/org/fusesource/jansi/HtmlAnsiOutputStream.java + +package almond.internals; + +import org.fusesource.jansi.AnsiOutputStream; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +public class HtmlAnsiOutputStream extends AnsiOutputStream { + + private boolean concealOn = false; + + @Override + public void close() throws IOException { + closeAttributes(); + super.close(); + } + + private static final String[] ANSI_COLOR_MAP = {"black", "red", + "green", "yellow", "blue", "magenta", "cyan", "white",}; + + private static final byte[] BYTES_QUOT = """.getBytes(); + private static final byte[] BYTES_AMP = "&".getBytes(); + private static final byte[] BYTES_LT = "<".getBytes(); + private static final byte[] BYTES_GT = ">".getBytes(); + + public HtmlAnsiOutputStream(OutputStream os) { + super(os); + } + + private final List closingAttributes = new ArrayList(); + + private void write(String s) throws IOException { + super.out.write(s.getBytes()); + } + + private void writeAttribute(String s) throws IOException { + write("<" + s + ">"); + closingAttributes.add(0, s.split(" ", 2)[0]); + } + + private void closeAttributes() throws IOException { + for (String attr : closingAttributes) { + write(""); + } + closingAttributes.clear(); + } + + public void write(int data) throws IOException { + switch (data) { + case 34: // " + out.write(BYTES_QUOT); + break; + case 38: // & + out.write(BYTES_AMP); + break; + case 60: // < + out.write(BYTES_LT); + break; + case 62: // > + out.write(BYTES_GT); + break; + default: + super.write(data); + } + } + + public void writeLine(byte[] buf, int offset, int len) throws IOException { + write(buf, offset, len); + closeAttributes(); + } + + @Override + protected void processSetAttribute(int attribute) throws IOException { + switch (attribute) { + case ATTRIBUTE_CONCEAL_ON: + write("\u001B[8m"); + concealOn = true; + break; + case ATTRIBUTE_INTENSITY_BOLD: + writeAttribute("b"); + break; + case ATTRIBUTE_INTENSITY_NORMAL: + closeAttributes(); + break; + case ATTRIBUTE_UNDERLINE: + writeAttribute("u"); + break; + case ATTRIBUTE_UNDERLINE_OFF: + closeAttributes(); + break; + case ATTRIBUTE_NEGATIVE_ON: + break; + case ATTRIBUTE_NEGATIVE_OFF: + break; + default: + break; + } + } + + @Override + protected void processAttributeRest() throws IOException { + if (concealOn) { + write("\u001B[0m"); + concealOn = false; + } + closeAttributes(); + } + + @Override + protected void processDefaultTextColor() throws IOException { + processAttributeRest(); + } + + @Override + protected void processSetForegroundColor(int color, boolean bright) throws IOException { + writeAttribute("span class=\"ansi-" + ANSI_COLOR_MAP[color] + "-fg\""); + } + + @Override + protected void processSetBackgroundColor(int color, boolean bright) throws IOException { + writeAttribute("span class=\"ansi-" + ANSI_COLOR_MAP[color] + "-bg\""); + } +} diff --git a/modules/scala/scala-interpreter/src/main/scala-2.11_2.12/almond/internals/ScalaInterpreterInspections.scala b/modules/scala/scala-interpreter/src/main/scala-2.11_2.12/almond/internals/ScalaInterpreterInspections.scala index 22b7dc3f0..ab462abce 100644 --- a/modules/scala/scala-interpreter/src/main/scala-2.11_2.12/almond/internals/ScalaInterpreterInspections.scala +++ b/modules/scala/scala-interpreter/src/main/scala-2.11_2.12/almond/internals/ScalaInterpreterInspections.scala @@ -9,20 +9,21 @@ import almond.interpreter._ import almond.interpreter.api.DisplayData import almond.logger.{Logger, LoggerContext} import ammonite.runtime.Frame +import ammonite.util.Ref import ammonite.util.Util.newLine import metabrowse.server.{MetabrowseServer, Sourcepath} import scala.tools.nsc.Global import scala.util.Random -trait ScalaInterpreterInspections extends Interpreter { - - def logCtx: LoggerContext - def metabrowse: Boolean - def metabrowseHost: String - def metabrowsePort: Int - def pressy: scala.tools.nsc.interactive.Global - def frames(): List[Frame] +final class ScalaInterpreterInspections( + logCtx: LoggerContext, + metabrowse: Boolean, + metabrowseHost: String, + metabrowsePort: Int, + pressy: => scala.tools.nsc.interactive.Global, + frames: Ref[List[Frame]] +) { private val log = logCtx(getClass) @@ -105,7 +106,7 @@ trait ScalaInterpreterInspections extends Interpreter { (server, port, windowName) } - override def inspect(code: String, pos: Int, detailLevel: Int): Option[Inspection] = + def inspect(code: String, pos: Int, detailLevel: Int): Option[Inspection] = metabrowseServerOpt().flatMap { case (metabrowseServer, metabrowsePort0, metabrowseWindowId) => val pressy0 = pressy @@ -163,7 +164,7 @@ trait ScalaInterpreterInspections extends Interpreter { } } - def inspectionsShutdown(): Unit = + def shutdown(): Unit = for ((metabrowseServer, _, _) <- metabrowseServerOpt0) { log.info("Stopping metabrowse server") metabrowseServer.stop() diff --git a/modules/scala/scala-interpreter/src/main/scala-2.13/almond/internals/ScalaInterpreterInspections.scala b/modules/scala/scala-interpreter/src/main/scala-2.13/almond/internals/ScalaInterpreterInspections.scala index 6076145e7..592294780 100644 --- a/modules/scala/scala-interpreter/src/main/scala-2.13/almond/internals/ScalaInterpreterInspections.scala +++ b/modules/scala/scala-interpreter/src/main/scala-2.13/almond/internals/ScalaInterpreterInspections.scala @@ -1,5 +1,19 @@ package almond.internals -trait ScalaInterpreterInspections { - def inspectionsShutdown(): Unit = () +import almond.interpreter._ +import almond.logger.LoggerContext +import ammonite.runtime.Frame +import ammonite.util.Ref + +final class ScalaInterpreterInspections( + logCtx: LoggerContext, + metabrowse: Boolean, + metabrowseHost: String, + metabrowsePort: Int, + pressy: => scala.tools.nsc.interactive.Global, + frames: Ref[List[Frame]] +) { + def inspect(code: String, pos: Int, detailLevel: Int): Option[Inspection] = + None + def shutdown(): Unit = () } diff --git a/modules/scala/scala-interpreter/src/main/scala/almond/AmmInterpreter.scala b/modules/scala/scala-interpreter/src/main/scala/almond/AmmInterpreter.scala new file mode 100644 index 000000000..fd0c556bd --- /dev/null +++ b/modules/scala/scala-interpreter/src/main/scala/almond/AmmInterpreter.scala @@ -0,0 +1,249 @@ +package almond + +import java.nio.file.{Files, Path} + +import almond.logger.LoggerContext +import ammonite.interp.{CodeWrapper, CompilerLifecycleManager, DefaultPreprocessor, Parsers, Preprocessor} +import ammonite.runtime.{Frame, Storage} +import ammonite.util.{Colors, Name, PredefInfo, Ref, Res} +import coursier.almond.tmp.Tmp +import fastparse.Parsed + +import scala.util.Properties + +object AmmInterpreter { + + private def predef = + """import almond.api.JupyterAPIHolder.value.{ + | publish, + | commHandler + |} + |import almond.api.JupyterAPIHolder.value.publish.display + |import almond.interpreter.api.DisplayData.DisplayDataSyntax + |import almond.display._ + |import almond.display.Display.{markdown, html, latex, text, js, svg} + |import almond.input._ + """.stripMargin + + private[almond] val isAtLeast_2_12_7 = { + val v = Properties.versionNumberString + !v.startsWith("2.11.") && (!v.startsWith("2.12.") || { + v.stripPrefix("2.12.").takeWhile(_.isDigit).toInt >= 7 + }) + } + + /** + * Instantiate an [[ammonite.interp.Interpreter]] to be used from [[ScalaInterpreter]]. + */ + def apply( + execute0: Execute, + storage: Storage, + replApi: ReplApiImpl, + jupyterApi: JupyterApiImpl, + predefCode: String, + predefFiles: Seq[Path], + frames0: Ref[List[Frame]], + codeWrapper: CodeWrapper, + extraRepos: Seq[String], + forceMavenProperties: Map[String, String], + mavenProfiles: Map[String, Boolean], + autoUpdateLazyVals: Boolean, + autoUpdateVars: Boolean, + logCtx: LoggerContext + ): ammonite.interp.Interpreter = { + + val predefFileInfos = + predefFiles.zipWithIndex.map { + case (path, idx) => + val suffix = if (idx <= 0) "" else s"-$idx" + PredefInfo( + Name("FilePredef" + suffix), + // read with the local charset… + new String(Files.readAllBytes(path)), + hardcoded = false, + Some(os.Path(path)) + ) + } + + val log = logCtx(getClass) + + try { + + log.info("Creating Ammonite interpreter") + + val ammInterp0: ammonite.interp.Interpreter = + new ammonite.interp.Interpreter( + execute0.printer, + storage = storage, + wd = ammonite.ops.pwd, + basePredefs = Seq( + PredefInfo( + Name("defaultPredef"), + predef + ammonite.main.Defaults.replPredef + ammonite.main.Defaults.predefString, + true, + None + ) + ), + customPredefs = predefFileInfos ++ Seq( + PredefInfo(Name("CodePredef"), predefCode, false, None) + ), + extraBridges = Seq( + (ammonite.repl.ReplBridge.getClass.getName.stripSuffix("$"), "repl", replApi), + (almond.api.JupyterAPIHolder.getClass.getName.stripSuffix("$"), "kernel", jupyterApi) + ), + colors = Ref(Colors.Default), + getFrame = () => frames0().head, + createFrame = () => { + val f = replApi.sess.childFrame(frames0().head); frames0() = f :: frames0(); f + }, + replCodeWrapper = codeWrapper, + scriptCodeWrapper = codeWrapper, + alreadyLoadedDependencies = ammonite.main.Defaults.alreadyLoadedDependencies("almond/almond-user-dependencies.txt") + ) { + override val compilerManager: CompilerLifecycleManager = + new CompilerLifecycleManager(storage, headFrame) { + import scala.reflect.internal.Flags + import scala.tools.nsc.{Global => G} + def customPprintSignature(ident: String, customMsg: Option[String], modOpt: Option[String], modErrOpt: Option[String]) = { + val customCode = customMsg.fold("_root_.scala.None")(x => s"""_root_.scala.Some("$x")""") + val modOptCode = modOpt.fold("_root_.scala.None")(x => s"""_root_.scala.Some($x)""") + val modErrOptCode = modErrOpt.fold("_root_.scala.None")(x => s"""_root_.scala.Some($x)""") + s"""_root_.almond + | .api + | .JupyterAPIHolder + | .value + | .Internal + | .printOnChange($ident, ${fastparse.internal.Util.literalize(ident)}, $customCode, $modOptCode, $modErrOptCode)""".stripMargin + } + override def preprocess(fileName: String): Preprocessor = + synchronized { + if (compiler == null) init(force = true) + new DefaultPreprocessor(compiler.parse(fileName, _)) { + + val CustomLazyDef = Processor { + case (_, code, t: G#ValDef) + if autoUpdateLazyVals && + !DefaultPreprocessor.isPrivate(t) && + !t.name.decoded.contains("$") && + t.mods.hasFlag(Flags.LAZY) => + val (code0, modOpt) = fastparse.parse(code, Parsers.PatVarSplitter(_)) match { + case Parsed.Success((lhs, rhs), _) if lhs.startsWith("lazy val ") => + val mod = Name.backtickWrap(t.name.decoded + "$value") + val c = s"""val $mod = new _root_.almond.api.internal.Lazy(() => $rhs) + |import $mod.{value => ${Name.backtickWrap(t.name.decoded)}} + |""".stripMargin + (c, Some(mod + ".onChange")) + case _ => + (code, None) + } + DefaultPreprocessor.Expanded( + code0, + Seq(customPprintSignature(Name.backtickWrap(t.name.decoded), Some("[lazy]"), None, modOpt)) + ) + } + + val CustomVarDef = Processor { + + case (_, code, t: G#ValDef) + if autoUpdateVars && + isAtLeast_2_12_7 && // https://github.com/scala/bug/issues/10886 + !DefaultPreprocessor.isPrivate(t) && + !t.name.decoded.contains("$") && + !t.mods.hasFlag(Flags.LAZY) => + val (code0, modOpt) = fastparse.parse(code, Parsers.PatVarSplitter(_)) match { + case Parsed.Success((lhs, rhs), _) if lhs.startsWith("var ") => + val mod = Name.backtickWrap(t.name.decoded + "$value") + val c = s"""val $mod = new _root_.almond.api.internal.Modifiable($rhs) + |import $mod.{value => ${Name.backtickWrap(t.name.decoded)}} + |""".stripMargin + (c, Some(mod + ".onChange")) + case _ => + (code, None) + } + DefaultPreprocessor.Expanded( + code0, + Seq(customPprintSignature(Name.backtickWrap(t.name.decoded), None, modOpt, None)) + ) + + } + override val decls = Seq[(String, String, G#Tree) => Option[DefaultPreprocessor.Expanded]]( + CustomLazyDef, CustomVarDef, + // same as super.decls + ObjectDef, ClassDef, TraitDef, DefDef, TypeDef, PatVarDef, Import, Expr + ) + } + } + } + } + + log.debug("Initializing interpreter predef") + + for ((e, _) <- ammInterp0.initializePredef()) + e match { + case Res.Failure(msg) => + throw new PredefException(msg, None) + case Res.Exception(t, msg) => + throw new PredefException(msg, Some(t)) + case Res.Skip => + case Res.Exit(v) => + log.warn(s"Ignoring exit request from predef (exit value: $v)") + } + + log.debug("Loading base dependencies") + + ammInterp0.repositories() = ammInterp0.repositories() ++ extraRepos.map { repo => + coursier.MavenRepository(repo) + } + + log.debug("Initializing Ammonite interpreter") + + ammInterp0.compilerManager.init() + + log.debug("Processing scalac args") + + ammInterp0.compilerManager.preConfigureCompiler(_.processArguments(Nil, processAll = true)) + + log.debug("Processing dependency-related params") + + if (forceMavenProperties.nonEmpty) + ammInterp0.resolutionHooks += { fetch => + val params0 = Tmp.resolutionParams(fetch) + val params = params0 + .withForcedProperties(params0.forcedProperties ++ forceMavenProperties) + fetch.withResolutionParams(params) + } + + if (mavenProfiles.nonEmpty) + ammInterp0.resolutionHooks += { fetch => + val mavenProfiles0 = mavenProfiles.toVector.map { + case (p, true) => p + case (p, false) => "!" + p + } + val params0 = Tmp.resolutionParams(fetch) + val params = params0 + .withProfiles(params0.profiles ++ mavenProfiles0) + fetch.withResolutionParams(params) + } + + log.info("Ammonite interpreter initialized") + + ammInterp0 + } catch { + case t: Throwable => + log.error(s"Caught exception while initializing interpreter", t) + throw t + } + } + + final class PredefException( + msg: String, + causeOpt: Option[Throwable] + ) extends Exception(msg, causeOpt.orNull) { + def describe: String = + if (causeOpt.isEmpty) + s"Error while running predef: $msg" + else + s"Caught exception while running predef: $msg" + } + +} diff --git a/modules/scala/scala-interpreter/src/main/scala/almond/Execute.scala b/modules/scala/scala-interpreter/src/main/scala/almond/Execute.scala new file mode 100644 index 000000000..dde82722b --- /dev/null +++ b/modules/scala/scala-interpreter/src/main/scala/almond/Execute.scala @@ -0,0 +1,390 @@ +package almond + +import java.io.ByteArrayOutputStream +import java.nio.charset.StandardCharsets +import java.nio.charset.StandardCharsets.UTF_8 + +import almond.api.JupyterApi +import almond.internals.{Capture, FunctionInputStream, FunctionOutputStream, HtmlAnsiOutputStream, UpdatableResults} +import almond.interpreter.ExecuteResult +import almond.interpreter.api.{CommHandler, DisplayData, OutputHandler} +import almond.interpreter.input.InputManager +import almond.logger.LoggerContext +import ammonite.interp.{Parsers, Preprocessor} +import ammonite.repl.{Repl, Signaller} +import ammonite.runtime.{History, Storage} +import ammonite.util.{Colors, Ex, Printer, Ref, Res} +import fastparse.Parsed + +import scala.collection.mutable +import scala.concurrent.{Await, ExecutionContext} +import scala.concurrent.duration.Duration +import scala.util.{Failure, Success} + +/** + * Wraps contextual things around when executing code (capturing output, stdin via front-ends, interruption, etc.) + */ +final class Execute( + trapOutput: Boolean, + automaticDependencies: Map[String, Seq[String]], + storage: Storage, + logCtx: LoggerContext, + updateBackgroundVariablesEcOpt: Option[ExecutionContext], + commHandlerOpt: => Option[CommHandler] +) { + + private val log = logCtx(getClass) + + private var currentInputManagerOpt0 = Option.empty[InputManager] + + private var interruptedStackTraceOpt0 = Option.empty[Array[StackTraceElement]] + private var currentThreadOpt0 = Option.empty[Thread] + + private var history0 = new History(Vector()) + + private val input0 = new FunctionInputStream( + UTF_8, + currentInputManagerOpt0.flatMap { m => + + val res = { + implicit val ec = ExecutionContext.global // just using that one to map over an existing future… + log.info("Awaiting input") + Await.result( + m.readInput() + .map(s => Success(s + System.lineSeparator())) + .recover { case t => Failure(t) }, + Duration.Inf + ) + } + log.info("Received input") + + res match { + case Success(s) => Some(s) + case Failure(_: InputManager.NoMoreInputException) => None + case Failure(e) => throw new Exception("Error getting more input", e) + } + } + ) + + private var currentPublishOpt0 = Option.empty[OutputHandler] + + private val capture0 = + if (trapOutput) + Capture.nop() + else + Capture.create() + + private val updatableResultsOpt0 = + updateBackgroundVariablesEcOpt.map { ec => + new UpdatableResults( + ec, + logCtx, + data => commHandlerOpt.foreach(_.updateDisplay(data)) // throw if commHandlerOpt is empty? + ) + } + + private val resultVariables = new mutable.HashMap[String, String] + private val resultOutput = new StringBuilder + private val resultStream = new FunctionOutputStream(20, 20, UTF_8, resultOutput.append(_)).printStream() + + private var currentLine0 = 0 + + private val printer0 = Printer( + capture0.out, + capture0.err, + resultStream, + s => currentPublishOpt0.fold(Console.err.println(s))(_.stderr(s)), + s => currentPublishOpt0.fold(Console.err.println(s))(_.stderr(s)), + s => currentPublishOpt0.fold(println(s))(_.stdout(s)) + ) + + def history: History = + history0 + + def printer: Printer = + printer0 + + def currentLine: Int = currentLine0 + def incrementLineCount(): Unit = { + currentLine0 += 1 + } + + def currentInputManagerOpt: Option[InputManager] = + currentInputManagerOpt0 + def currentPublishOpt: Option[OutputHandler] = + currentPublishOpt0 + + lazy val updatableResults: JupyterApi.UpdatableResults = + new JupyterApi.UpdatableResults { + override def updatable(k: String, v: String) = + resultVariables += k -> v + override def update(k: String, v: String, last: Boolean) = + updatableResultsOpt0 match { + case None => throw new Exception("Results updating not available") + case Some(r) => r.update(k, v, last) + } + } + + private def withInputManager[T](m: Option[InputManager])(f: => T): T = { + val previous = currentInputManagerOpt0 + try { + currentInputManagerOpt0 = m + f + } finally { + currentInputManagerOpt0 = previous + m.foreach(_.done()) + } + } + + private def withClientStdin[T](t: => T): T = + Console.withIn(input0) { + val previous = System.in + try { + System.setIn(input0) + t + } finally { + System.setIn(previous) + input0.clear() + } + } + + private def withOutputHandler[T](handlerOpt: Option[OutputHandler])(f: => T): T = { + val previous = currentPublishOpt0 + try { + currentPublishOpt0 = handlerOpt + f + } finally { + currentPublishOpt0 = previous + } + } + + private def capturingOutput[T](t: => T): T = + currentPublishOpt0 match { + case None => t + case Some(p) => capture0(p.stdout, p.stderr)(t) + } + + private def interruptible[T](t: => T): T = { + interruptedStackTraceOpt0 = None + currentThreadOpt0 = Some(Thread.currentThread()) + try { + Signaller("INT") { + currentThreadOpt0 match { + case None => + log.warn("Received SIGINT, but no execution is running") + case Some(t) => + interruptedStackTraceOpt0 = Some(t.getStackTrace) + log.debug(s"Received SIGINT, stopping thread $t\n${interruptedStackTraceOpt0.map(" " + _).mkString("\n")}") + t.stop() + } + }.apply { + t + } + } finally { + currentThreadOpt0 = None + } + } + + def interrupt(): Unit = + currentThreadOpt0 match { + case None => + log.warn("Interrupt asked, but no execution is running") + case Some(t) => + log.debug(s"Interrupt asked, stopping thread $t\n${t.getStackTrace.map(" " + _).mkString("\n")}") + t.stop() + } + + + private var lastExceptionOpt0 = Option.empty[Throwable] + + def lastExceptionOpt: Option[Throwable] = lastExceptionOpt0 + + + private def ammResult( + ammInterp: ammonite.interp.Interpreter, + code: String, + inputManager: Option[InputManager], + outputHandler: Option[OutputHandler] + ) = + withOutputHandler(outputHandler) { + for { + (code, stmts) <- fastparse.parse(code, Parsers.Splitter(_)) match { + case Parsed.Success(value, _) => + Res.Success((code, value)) + case f: Parsed.Failure => Res.Failure( + Preprocessor.formatFastparseError("(console)", code, f) + ) + } + _ = log.debug(s"splitted $code") + ev <- interruptible { + withInputManager(inputManager) { + withClientStdin { + capturingOutput { + resultOutput.clear() + resultVariables.clear() + log.debug(s"Compiling / evaluating $code ($stmts)") + val r = ammInterp.processLine(code, stmts, currentLine0, silent = false, incrementLine = () => currentLine0 += 1) + + log.debug(s"Handling output of $code") + Repl.handleOutput(ammInterp, r) + r match { + case Res.Exception(ex, _) => + lastExceptionOpt0 = Some(ex) + case _ => + } + + val variables = resultVariables.toMap + val res0 = resultOutput.result() + log.debug(s"Result of $code: $res0") + resultOutput.clear() + resultVariables.clear() + val data = + if (variables.isEmpty) { + if (res0.isEmpty) + DisplayData.empty + else + DisplayData.text(res0) + } else + updatableResultsOpt0 match { + case None => + DisplayData.text(res0) + case Some(r) => + val baos = new ByteArrayOutputStream + val haos = new HtmlAnsiOutputStream(baos) + haos.write(res0.getBytes(StandardCharsets.UTF_8)) + haos.close() + val html = + s"""""".stripMargin + log.debug(s"HTML: $html") + val d = r.add( + almond.display.Data( + almond.display.Text.mimeType -> res0, + almond.display.Html.mimeType -> html + ).displayData(), + variables + ) + outputHandler match { + case None => + d + case Some(h) => + h.display(d) + DisplayData.empty + } + } + r.map((_, data)) + } + } + } + } + } yield ev + } + + def apply( + ammInterp: ammonite.interp.Interpreter, + code: String, + inputManager: Option[InputManager], + outputHandler: Option[OutputHandler], + colors0: Ref[Colors] + ): ExecuteResult = { + + storage.fullHistory() = storage.fullHistory() :+ code + history0 = history0 :+ code + + val hackedLine = + if (code.contains("$ivy.`")) + automaticDependencies.foldLeft(code) { + case (line0, (triggerDep, autoDeps)) => + if (line0.contains(triggerDep)) { + log.info(s"Adding auto dependencies $autoDeps") + autoDeps.map(dep => s"import $$ivy.`$dep`; ").mkString + line0 + } else + line0 + } + else + code + + ammResult(ammInterp, hackedLine, inputManager, outputHandler) match { + case Res.Success((_, data)) => + ExecuteResult.Success(data) + case Res.Failure(msg) => + interruptedStackTraceOpt0 match { + case None => + val err = Execute.error(colors0(), None, msg) + outputHandler.foreach(_.stderr(err.message)) // necessary? + err + case Some(st) => + + val cutoff = Set("$main", "evaluatorRunPrinter") + + ExecuteResult.Error( + ( + "Interrupted!" +: st + .takeWhile(x => !cutoff(x.getMethodName)) + .map(Execute.highlightFrame(_, fansi.Attr.Reset, colors0().literal())) + ).mkString(System.lineSeparator()) + ) + } + + case Res.Exception(ex, msg) => + log.error(s"exception in user code (${ex.getMessage})", ex) + Execute.error(colors0(), Some(ex), msg) + + case Res.Skip => + ExecuteResult.Success() + + case Res.Exit(_) => + ExecuteResult.Exit + } + } +} + +object Execute { + + // these come from Ammonite + // exception display was tweaked a bit (too much red for notebooks else) + + private def highlightFrame(f: StackTraceElement, + highlightError: fansi.Attrs, + source: fansi.Attrs) = { + val src = + if (f.isNativeMethod) source("Native Method") + else if (f.getFileName == null) source("Unknown Source") + else source(f.getFileName) ++ ":" ++ source(f.getLineNumber.toString) + + val prefix :+ clsName = f.getClassName.split('.').toSeq + val prefixString = prefix.map(_+'.').mkString("") + val clsNameString = clsName //.replace("$", error("$")) + val method = + fansi.Str(prefixString) ++ highlightError(clsNameString) ++ "." ++ + highlightError(f.getMethodName) + + fansi.Str(s" ") ++ method ++ "(" ++ src ++ ")" + } + + def showException(ex: Throwable, + error: fansi.Attrs, + highlightError: fansi.Attrs, + source: fansi.Attrs) = { + + val cutoff = Set("$main", "evaluatorRunPrinter") + val traces = Ex.unapplySeq(ex).get.map(exception => + error(exception.toString).render + System.lineSeparator() + + exception + .getStackTrace + .takeWhile(x => !cutoff(x.getMethodName)) + .map(highlightFrame(_, highlightError, source)) + .mkString(System.lineSeparator()) + ) + traces.mkString(System.lineSeparator()) + } + + private def error(colors: Colors, exOpt: Option[Throwable], msg: String) = + ExecuteResult.Error( + msg + exOpt.fold("")(ex => (if (msg.isEmpty) "" else "\n") + showException( + ex, colors.error(), fansi.Attr.Reset, colors.literal() + )) + ) + +} diff --git a/modules/scala/scala-interpreter/src/main/scala/almond/JupyterApiImpl.scala b/modules/scala/scala-interpreter/src/main/scala/almond/JupyterApiImpl.scala new file mode 100644 index 000000000..2b323dc70 --- /dev/null +++ b/modules/scala/scala-interpreter/src/main/scala/almond/JupyterApiImpl.scala @@ -0,0 +1,57 @@ +package almond + +import java.io.ByteArrayOutputStream +import java.nio.charset.StandardCharsets + +import almond.api.{FullJupyterApi, JupyterApi} +import almond.internals.HtmlAnsiOutputStream +import almond.interpreter.api.CommHandler +import pprint.{TPrint, TPrintColors} + +import scala.concurrent.Await +import scala.concurrent.duration.Duration +import scala.reflect.ClassTag + +/** Actual [[almond.api.JupyterApi]] instance */ +final class JupyterApiImpl( + execute: Execute, + commHandlerOpt: => Option[CommHandler], + replApi: ReplApiImpl +) extends FullJupyterApi { + + protected def printOnChange[T]( + value: => T, + ident: String, + custom: Option[String], + onChange: Option[(T => Unit) => Unit], + onChangeOrError: Option[(Either[Throwable, T] => Unit) => Unit] + )(implicit + tprint: TPrint[T], + tcolors: TPrintColors, + classTagT: ClassTag[T] + ): Iterator[String] = + replApi.printSpecial(value, ident, custom, onChange, onChangeOrError, replApi.pprinter, Some(updatableResults))(tprint, tcolors, classTagT).getOrElse { + replApi.Internal.print(value, ident, custom)(tprint, tcolors, classTagT) + } + + protected def ansiTextToHtml(text: String): String = { + val baos = new ByteArrayOutputStream + val haos = new HtmlAnsiOutputStream(baos) + haos.write(text.getBytes(StandardCharsets.UTF_8)) + haos.close() + baos.toString("UTF-8") + } + + def stdinOpt(prompt: String, password: Boolean): Option[String] = + for (m <- execute.currentInputManagerOpt) + yield Await.result(m.readInput(prompt, password), Duration.Inf) + + override def changingPublish = + execute.currentPublishOpt.getOrElse(super.changingPublish) + override def commHandler = + commHandlerOpt.getOrElse(super.commHandler) + + protected def updatableResults0: JupyterApi.UpdatableResults = + execute.updatableResults +} + diff --git a/modules/scala/scala-interpreter/src/main/scala/almond/ReplApiImpl.scala b/modules/scala/scala-interpreter/src/main/scala/almond/ReplApiImpl.scala new file mode 100644 index 000000000..75e43a816 --- /dev/null +++ b/modules/scala/scala-interpreter/src/main/scala/almond/ReplApiImpl.scala @@ -0,0 +1,206 @@ +package almond + +import almond.api.JupyterApi +import almond.interpreter.api.DisplayData +import ammonite.ops.read +import ammonite.repl.{FrontEnd, FullReplAPI, ReplLoad, SessionApiImpl} +import ammonite.runtime.Storage +import ammonite.util.{Bind, Colors, CompilationError, Ref, Res} +import ammonite.util.Util.normalizeNewlines +import fansi.Attr +import jupyter.{Displayer, Displayers} +import pprint.{TPrint, TPrintColors} + +import scala.collection.mutable +import scala.reflect.ClassTag + +/** Actual [[ammonite.repl.ReplAPI]] instance */ +final class ReplApiImpl( + execute0: Execute, + storage: Storage, + colors0: Ref[Colors], + ammInterp: => ammonite.interp.Interpreter, + sess0: SessionApiImpl +) extends ammonite.repl.ReplApiImpl { self => + + + private val defaultDisplayer = Displayers.registration().find(classOf[almond.ReplApiImpl.Foo]) + + def printSpecial[T]( + value: => T, + ident: String, + custom: Option[String], + onChange: Option[(T => Unit) => Unit], + onChangeOrError: Option[(Either[Throwable, T] => Unit) => Unit], + pprinter: Ref[pprint.PPrinter], + updatableResultsOpt: Option[JupyterApi.UpdatableResults] + )(implicit tprint: TPrint[T], tcolors: TPrintColors, classTagT: ClassTag[T]): Option[Iterator[String]] = + execute0.currentPublishOpt match { + case None => + None + case Some(p) => + + val isUpdatableDisplay = + classTagT != null && + classOf[almond.display.Display] + .isAssignableFrom(classTagT.runtimeClass) + + val jvmReprDisplayer: Displayer[_] = + Displayers.registration().find(classTagT.runtimeClass) + val useJvmReprDisplay = + jvmReprDisplayer ne defaultDisplayer + + if (isUpdatableDisplay) { + val d = value.asInstanceOf[almond.display.Display] + d.display()(p) + Some(Iterator()) + } else if (useJvmReprDisplay) { + import scala.collection.JavaConverters._ + val m = jvmReprDisplayer + .asInstanceOf[Displayer[T]] + .display(value) + .asScala + .toMap + p.display(DisplayData(m)) + Some(Iterator()) + } else + for (updatableResults <- updatableResultsOpt if (onChange.nonEmpty && custom.isEmpty) || (onChangeOrError.nonEmpty && custom.nonEmpty)) yield { + + // Pre-compute how many lines and how many columns the prefix of the + // printed output takes, so we can feed that information into the + // pretty-printing of the main body + val prefix = new pprint.Truncated( + Iterator( + colors0().ident()(ident).render, ": ", + implicitly[pprint.TPrint[T]].render(tcolors), " = " + ), + pprinter().defaultWidth, + pprinter().defaultHeight + ) + val output = mutable.Buffer.empty[fansi.Str] + + prefix.foreach(output.+=) + + val rhs = custom match { + case None => + + var currentValue = value // FIXME May be better to only hold weak references here + // (in case we allow to wipe previous values at some point) + + val id = updatableResults.updatable { + pprinter().tokenize( + currentValue, + height = pprinter().defaultHeight - prefix.completedLineCount, + initialOffset = prefix.lastLineLength + ).map(_.render).mkString + } + + onChange.foreach(_ { value0 => + if (value0 != currentValue) { + val s = pprinter().tokenize( + value0, + height = pprinter().defaultHeight - prefix.completedLineCount, + initialOffset = prefix.lastLineLength + ) + updatableResults.update(id, s.map(_.render).mkString, last = false) + currentValue = value0 + } + }) + + id + + case Some(s) => + + val messageColor = Some(pprinter().colorLiteral) + .filter(_ == fansi.Attrs.Empty) + .getOrElse(fansi.Color.LightGray) + + val id = updatableResults.updatable { + messageColor(s).render + } + + onChangeOrError.foreach(_ { + case Left(ex) => + + val messageColor = Some(pprinter().colorLiteral) + .filter(_ == fansi.Attrs.Empty) + .getOrElse(fansi.Color.LightGray) + + val err = Execute.showException(ex, colors0().error(), Attr.Reset, colors0().literal()) + val s = messageColor("[last attempt failed]").render + "\n" + err + updatableResults.update(id, s, last = false) + case Right(value0) => + val s = pprinter().tokenize( + value0, + height = pprinter().defaultHeight - prefix.completedLineCount, + initialOffset = prefix.lastLineLength + ) + updatableResults.update(id, s.map(_.render).mkString, last = true) + }) + + id + } + + output.iterator.map(_.render) ++ Iterator(rhs) + } + } + + + + def replArgs0 = Vector.empty[Bind[_]] + def printer = execute0.printer + + def sess = sess0 + val prompt = Ref("nope") + val frontEnd = Ref[FrontEnd](null) + def lastException: Throwable = execute0.lastExceptionOpt.orNull + def fullHistory = storage.fullHistory() + def history = execute0.history + val colors = colors0 + def newCompiler() = ammInterp.compilerManager.init(force = true) + def compiler = ammInterp.compilerManager.compiler.compiler + def interactiveCompiler = ammInterp.compilerManager.pressy.compiler + def fullImports = ammInterp.predefImports ++ imports + def imports = ammInterp.frameImports + def usedEarlierDefinitions = ammInterp.frameUsedEarlierDefinitions + def width = 80 + def height = 80 + + val load: ReplLoad = + new ReplLoad { + def apply(line: String) = + ammInterp.processExec(line, execute0.currentLine, () => execute0.incrementLineCount()) match { + case Res.Failure(s) => throw new CompilationError(s) + case Res.Exception(t, _) => throw t + case _ => + } + + def exec(file: ammonite.ops.Path): Unit = { + ammInterp.watch(file) + apply(normalizeNewlines(read(file))) + } + } + + override protected[this] def internal0: FullReplAPI.Internal = + new FullReplAPI.Internal { + def pprinter = self.pprinter + def colors = self.colors + def replArgs: IndexedSeq[Bind[_]] = replArgs0 + + val defaultDisplayer = Displayers.registration().find(classOf[ReplApiImpl.Foo]) + + override def print[T]( + value: => T, + ident: String, + custom: Option[String] + )(implicit tprint: TPrint[T], tcolors: TPrintColors, classTagT: ClassTag[T]): Iterator[String] = + printSpecial(value, ident, custom, None, None, pprinter, None)(tprint, tcolors, classTagT).getOrElse { + super.print(value, ident, custom)(tprint, tcolors, classTagT) + } + } +} + +object ReplApiImpl { + private class Foo +} + diff --git a/modules/scala/scala-interpreter/src/main/scala/almond/ScalaInterpreter.scala b/modules/scala/scala-interpreter/src/main/scala/almond/ScalaInterpreter.scala index 299eb5a22..bf384760b 100644 --- a/modules/scala/scala-interpreter/src/main/scala/almond/ScalaInterpreter.scala +++ b/modules/scala/scala-interpreter/src/main/scala/almond/ScalaInterpreter.scala @@ -1,422 +1,105 @@ package almond -import java.nio.charset.StandardCharsets.UTF_8 -import java.nio.file.{Files, Path} - -import almond.api.JupyterApi -import almond.api.helpers.Display import almond.internals._ import almond.interpreter._ -import almond.interpreter.api.{CommHandler, DisplayData, OutputHandler} +import almond.interpreter.api.{CommHandler, OutputHandler} import almond.interpreter.input.InputManager -import almond.interpreter.util.CancellableFuture +import almond.interpreter.util.AsyncInterpreterOps import almond.logger.LoggerContext import almond.protocol.KernelInfo -import ammonite.interp.{CodeClassWrapper, CodeWrapper, Parsers, Preprocessor} -import ammonite.ops.read -import ammonite.repl._ +import ammonite.interp.Parsers +import ammonite.repl.{ReplApiImpl => _, _} import ammonite.runtime._ import ammonite.util._ -import ammonite.util.Util.{newLine, normalizeNewlines} -import coursier.almond.tmp.Tmp import fastparse.Parsed import io.github.soc.directories.ProjectDirectories -import jupyter.{Displayer, Displayers} -import pprint.{TPrint, TPrintColors} -import scala.collection.mutable -import scala.concurrent.{Await, ExecutionContext} -import scala.concurrent.duration.Duration -import scala.reflect.ClassTag -import scala.util.{Failure, Success} +import scala.util.control.NonFatal +/** Holds bits of state for the interpreter, and implements [[almond.interpreter.Interpreter]]. */ final class ScalaInterpreter( - updateBackgroundVariablesEcOpt: Option[ExecutionContext] = None, - extraRepos: Seq[String] = Nil, - extraBannerOpt: Option[String] = None, - extraLinks: Seq[KernelInfo.Link] = Nil, - predefCode: String = "", - predefFiles: Seq[Path] = Nil, - automaticDependencies: Map[String, Seq[String]] = Map(), - forceMavenProperties: Map[String, String] = Map(), - mavenProfiles: Map[String, Boolean] = Map(), - codeWrapper: CodeWrapper = CodeClassWrapper, - initialColors: Colors = Colors.Default, - initialClassLoader: ClassLoader = Thread.currentThread().getContextClassLoader, - val logCtx: LoggerContext = LoggerContext.nop, - val metabrowse: Boolean = false, - val metabrowseHost: String = "localhost", - val metabrowsePort: Int = -1, - lazyInit: Boolean = false, - trapOutput: Boolean = false, - disableCache: Boolean = false -) extends Interpreter with ScalaInterpreterInspections { scalaInterp => + params: ScalaInterpreterParams = ScalaInterpreterParams(), + val logCtx: LoggerContext = LoggerContext.nop +) extends Interpreter with AsyncInterpreterOps { private val log = logCtx(getClass) - def pressy = ammInterp.compilerManager.pressy.compiler - - private val colors0 = Ref[Colors](initialColors) - private val history0 = new History(Vector()) - - private var currentInputManagerOpt = Option.empty[InputManager] - - private var currentPublishOpt = Option.empty[OutputHandler] - - private val input = new FunctionInputStream( - UTF_8, - currentInputManagerOpt.flatMap { m => - - val res = { - implicit val ec = ExecutionContext.global // just using that one to map over an existing future… - log.info("Awaiting input") - Await.result( - m.readInput() - .map(s => Success(s + newLine)) - .recover { case t => Failure(t) }, - Duration.Inf - ) - } - log.info("Received input") + private val frames0: Ref[List[Frame]] = Ref(List(Frame.createInitial(params.initialClassLoader))) - res match { - case Success(s) => Some(s) - case Failure(_: InputManager.NoMoreInputException) => None - case Failure(e) => throw new Exception("Error getting more input", e) - } - } + private val inspections = new ScalaInterpreterInspections( + logCtx, + params.metabrowse, + params.metabrowseHost, + params.metabrowsePort, + ammInterp.compilerManager.pressy.compiler, + frames0 ) - private val capture = - if (trapOutput) - Capture.nop() - else - Capture.create() + private val colors0: Ref[Colors] = Ref(params.initialColors) private var commHandlerOpt = Option.empty[CommHandler] - private val updatableResultsOpt = - updateBackgroundVariablesEcOpt.map { ec => - new UpdatableResults( - ec, - logCtx, - data => commHandlerOpt.foreach(_.updateDisplay(data)) // throw if commHandlerOpt is empty? - ) - } - - private val resultVariables = new mutable.HashMap[String, String] - private val resultOutput = new StringBuilder - private val resultStream = new FunctionOutputStream(20, 20, UTF_8, resultOutput.append(_)).printStream() - private val storage = - if (disableCache) + if (params.disableCache) Storage.InMemory() else new Storage.Folder(os.Path(ProjectDirectories.from(null, null, "Almond").cacheDir) / "ammonite") - private val frames0 = Ref(List(Frame.createInitial(initialClassLoader))) - private val sess0 = new SessionApiImpl(frames0) - private var currentLine0 = 0 - - def frames(): List[Frame] = frames0() - - private val printer0 = Printer( - capture.out, - capture.err, - resultStream, - s => currentPublishOpt.fold(Console.err.println(s))(_.stderr(s)), - s => currentPublishOpt.fold(Console.err.println(s))(_.stderr(s)), - s => currentPublishOpt.fold(println(s))(_.stdout(s)) + private val execute0 = new Execute( + params.trapOutput, + params.automaticDependencies, + storage, + logCtx, + params.updateBackgroundVariablesEcOpt, + commHandlerOpt ) - private def withInputManager[T](m: Option[InputManager])(f: => T): T = { - val previous = currentInputManagerOpt - try { - currentInputManagerOpt = m - f - } finally { - currentInputManagerOpt = previous - m.foreach(_.done()) - } - } - - private def withOutputHandler[T](handlerOpt: Option[OutputHandler])(f: => T): T = { - val previous = currentPublishOpt - try { - currentPublishOpt = handlerOpt - f - } finally { - currentPublishOpt = previous - } - } - - private def withClientStdin[T](t: => T): T = - Console.withIn(input) { - val previous = System.in - try { - System.setIn(input) - t - } finally { - System.setIn(previous) - input.clear() - } - } + lazy val ammInterp: ammonite.interp.Interpreter = { - private def capturingOutput[T](t: => T): T = - currentPublishOpt match { - case None => t - case Some(p) => capture(p.stdout, p.stderr)(t) - } + val sessApi = new SessionApiImpl(frames0) + val replApi = + new ReplApiImpl( + execute0, + storage, + colors0, + ammInterp, + sessApi + ) - lazy val ammInterp: ammonite.interp.Interpreter = { + val jupyterApi = + new JupyterApiImpl(execute0, commHandlerOpt, replApi) - val replApi: ReplApiImpl = - new ReplApiImpl { self => - def replArgs0 = Vector.empty[Bind[_]] - def printer = printer0 - - def sess = sess0 - val prompt = Ref("nope") - val frontEnd = Ref[FrontEnd](null) - def lastException: Throwable = null - def fullHistory = storage.fullHistory() - def history = history0 - val colors = colors0 - def newCompiler() = ammInterp.compilerManager.init(force = true) - def compiler = ammInterp.compilerManager.compiler.compiler - def interactiveCompiler = ammInterp.compilerManager.pressy.compiler - def fullImports = ammInterp.predefImports ++ imports - def imports = ammInterp.frameImports - def usedEarlierDefinitions = ammInterp.frameUsedEarlierDefinitions - def width = 80 - def height = 80 - - val load: ReplLoad = - new ReplLoad { - def apply(line: String) = - ammInterp.processExec(line, currentLine0, () => currentLine0 += 1) match { - case Res.Failure(s) => throw new CompilationError(s) - case Res.Exception(t, _) => throw t - case _ => - } - - def exec(file: ammonite.ops.Path): Unit = { - ammInterp.watch(file) - apply(normalizeNewlines(read(file))) - } - } - - override protected[this] def internal0: FullReplAPI.Internal = - new FullReplAPI.Internal { - def pprinter = self.pprinter - def colors = self.colors - def replArgs: IndexedSeq[Bind[_]] = replArgs0 - - val defaultDisplayer = Displayers.registration().find(classOf[ScalaInterpreter.Foo]) - - override def print[T]( - value: => T, - ident: String, - custom: Option[String] - )(implicit tprint: TPrint[T], tcolors: TPrintColors, classTagT: ClassTag[T]): Iterator[String] = { - - val displayerPublishOpt = - if (classTagT == null) - None - else - currentPublishOpt.flatMap { p => - Some(Displayers.registration().find(classTagT.runtimeClass)) - .filter(_ ne defaultDisplayer) - .map(d => (d.asInstanceOf[Displayer[T]], p)) - } - - displayerPublishOpt match { - case None => - super.print(value, ident, custom)(tprint, tcolors, classTagT) - case Some((displayer, publish)) => - import scala.collection.JavaConverters._ - val m = displayer.display(value) - val data = DisplayData(m.asScala.toMap) - publish.display(data) - Iterator() - } - } - } - } - - val jupyterApi: JupyterApi = - new JupyterApi { - - def stdinOpt(prompt: String, password: Boolean): Option[String] = - for (m <- currentInputManagerOpt) - yield Await.result(m.readInput(prompt, password), Duration.Inf) - - override def changingPublish = - currentPublishOpt.getOrElse(super.changingPublish) - override def commHandler = - commHandlerOpt.getOrElse(super.commHandler) - - protected def updatableResults0: JupyterApi.UpdatableResults = - new JupyterApi.UpdatableResults { - override def addVariable(k: String, v: String) = - resultVariables += k -> v - override def updateVariable(k: String, v: String, last: Boolean) = - updatableResultsOpt match { - case None => throw new Exception("Results updating not available") - case Some(r) => r.update(k, v, last) - } - } - } - - for (ec <- updateBackgroundVariablesEcOpt) + for (ec <- params.updateBackgroundVariablesEcOpt) UpdatableFuture.setup(replApi, jupyterApi, ec) - val predefFileInfos = - predefFiles.zipWithIndex.map { - case (path, idx) => - val suffix = if (idx <= 0) "" else s"-$idx" - PredefInfo( - Name("FilePredef" + suffix), - // read with the local charset… - new String(Files.readAllBytes(path)), - false, - Some(os.Path(path)) - ) - } - - try { - - log.info("Creating Ammonite interpreter") - - val ammInterp0: ammonite.interp.Interpreter = - new ammonite.interp.Interpreter( - printer0, - storage = storage, - wd = ammonite.ops.pwd, - basePredefs = Seq( - PredefInfo( - Name("defaultPredef"), - ScalaInterpreter.predef + ammonite.main.Defaults.replPredef + ammonite.main.Defaults.predefString, - true, - None - ) - ), - customPredefs = predefFileInfos ++ Seq( - PredefInfo(Name("CodePredef"), predefCode, false, None) - ), - extraBridges = Seq( - (ammonite.repl.ReplBridge.getClass.getName.stripSuffix("$"), "repl", replApi), - (almond.api.JupyterAPIHolder.getClass.getName.stripSuffix("$"), "kernel", jupyterApi) - ), - colors = Ref(Colors.Default), - getFrame = () => frames0().head, - createFrame = () => { - val f = sess0.childFrame(frames0().head); frames0() = f :: frames0(); f - }, - replCodeWrapper = codeWrapper, - scriptCodeWrapper = codeWrapper, - alreadyLoadedDependencies = ammonite.main.Defaults.alreadyLoadedDependencies("almond/almond-user-dependencies.txt") - ) - - log.info("Initializing interpreter predef") - - for ((e, _) <- ammInterp0.initializePredef()) - e match { - case Res.Failure(msg) => - throw new ScalaInterpreter.PredefException(msg, None) - case Res.Exception(t, msg) => - throw new ScalaInterpreter.PredefException(msg, Some(t)) - case Res.Skip => - case Res.Exit(v) => - log.warn(s"Ignoring exit request from predef (exit value: $v)") - } - - log.info("Loading base dependencies") - - ammInterp0.repositories() = ammInterp0.repositories() ++ extraRepos.map { repo => - coursier.MavenRepository(repo) - } - - log.info("Initializing Ammonite interpreter") - - ammInterp0.compilerManager.init() - - log.info("Processing scalac args") - - ammInterp0.compilerManager.preConfigureCompiler(_.processArguments(Nil, processAll = true)) - - log.info("Ammonite interpreter ok") - - if (forceMavenProperties.nonEmpty) - ammInterp0.resolutionHooks += { fetch => - val params0 = Tmp.resolutionParams(fetch) - val params = params0 - .withForcedProperties(params0.forcedProperties ++ forceMavenProperties) - fetch.withResolutionParams(params) - } - - if (mavenProfiles.nonEmpty) - ammInterp0.resolutionHooks += { fetch => - val mavenProfiles0 = mavenProfiles.toVector.map { - case (p, true) => p - case (p, false) => "!" + p - } - val params0 = Tmp.resolutionParams(fetch) - val params = params0 - .withProfiles(params0.profiles ++ mavenProfiles0) - fetch.withResolutionParams(params) - } - - ammInterp0 - } catch { - case t: Throwable => - log.error(s"Caught exception while initializing interpreter", t) - throw t - } + AmmInterpreter( + execute0, + storage, + replApi, + jupyterApi, + params.predefCode, + params.predefFiles, + frames0, + params.codeWrapper, + params.extraRepos, + params.forceMavenProperties, + params.mavenProfiles, + params.autoUpdateLazyVals, + params.autoUpdateVars, + logCtx + ) } - if (!lazyInit) + if (!params.lazyInit) // eagerly initialize ammInterp ammInterp - private var interruptedStackTraceOpt = Option.empty[Array[StackTraceElement]] - private var currentThreadOpt = Option.empty[Thread] - override def interruptSupported: Boolean = true - override def interrupt(): Unit = { - currentThreadOpt match { - case None => - log.warn("Interrupt asked, but no execution is running") - case Some(t) => - log.debug(s"Interrupt asked, stopping thread $t\n${t.getStackTrace.map(" " + _).mkString("\n")}") - t.stop() - } - } - - private def interruptible[T](t: => T): T = { - interruptedStackTraceOpt = None - currentThreadOpt = Some(Thread.currentThread()) - try { - Signaller("INT") { - currentThreadOpt match { - case None => - log.warn("Received SIGINT, but no execution is running") - case Some(t) => - interruptedStackTraceOpt = Some(t.getStackTrace) - log.debug(s"Received SIGINT, stopping thread $t\n${interruptedStackTraceOpt.map(" " + _).mkString("\n")}") - t.stop() - } - }.apply { - t - } - } finally { - currentThreadOpt = None - } - } - + override def interrupt(): Unit = + execute0.interrupt() override def supportComm: Boolean = true override def setCommHandler(commHandler0: CommHandler): Unit = @@ -427,116 +110,11 @@ final class ScalaInterpreter( storeHistory: Boolean, // FIXME Take that one into account inputManager: Option[InputManager], outputHandler: Option[OutputHandler] - ): ExecuteResult = { - - val hackedLine = - if (code.contains("$ivy.`")) - automaticDependencies.foldLeft(code) { - case (line0, (triggerDep, autoDeps)) => - if (line0.contains(triggerDep)) { - log.info(s"Adding auto dependencies $autoDeps") - autoDeps.map(dep => s"import $$ivy.`$dep`; ").mkString + line0 - } else - line0 - } - else - code - - val ammInterp0 = ammInterp // ensures we don't capture output / catch signals during interp initialization - - val ammResult = - withOutputHandler(outputHandler) { - for { - (code, stmts) <- fastparse.parse(hackedLine, Parsers.Splitter(_)) match { - case Parsed.Success(value, _) => - Res.Success((hackedLine, value)) - case f: Parsed.Failure => Res.Failure( - Preprocessor.formatFastparseError("(console)", code, f) - ) - } - _ = log.info(s"splitted $hackedLine") - ev <- interruptible { - withInputManager(inputManager) { - withClientStdin { - capturingOutput { - resultOutput.clear() - resultVariables.clear() - log.info(s"Compiling / evaluating $code ($stmts)") - val r = ammInterp0.processLine(code, stmts, currentLine0, silent = false, incrementLine = () => currentLine0 += 1) - log.info(s"Handling output of $hackedLine") - Repl.handleOutput(ammInterp0, r) - val variables = resultVariables.toMap - val res0 = resultOutput.result() - log.info(s"Result of $hackedLine: $res0") - resultOutput.clear() - resultVariables.clear() - val data = - if (variables.isEmpty) { - if (res0.isEmpty) - DisplayData.empty - else - DisplayData.text(res0) - } else - updatableResultsOpt match { - case None => - DisplayData.text(res0) - case Some(r) => - val d = r.add( - DisplayData.text(res0).withId(Display.newId()), - variables - ) - outputHandler match { - case None => - d - case Some(h) => - h.display(d) - DisplayData.empty - } - } - r.map((_, data)) - } - } - } - } - } yield ev - } - - ammResult match { - case Res.Success((_, data)) => - ExecuteResult.Success(data) - case Res.Failure(msg) => - interruptedStackTraceOpt match { - case None => - val err = ScalaInterpreter.error(colors0(), None, msg) - outputHandler.foreach(_.stderr(err.message)) // necessary? - err - case Some(st) => - - val cutoff = Set("$main", "evaluatorRunPrinter") - - ExecuteResult.Error( - ( - "Interrupted!" +: st - .takeWhile(x => !cutoff(x.getMethodName)) - .map(ScalaInterpreter.highlightFrame(_, fansi.Attr.Reset, colors0().literal())) - ).mkString(newLine) - ) - } - - case Res.Exception(ex, msg) => - log.error(s"exception in user code (${ex.getMessage})", ex) - ScalaInterpreter.error(colors0(), Some(ex), msg) - - case Res.Skip => - ExecuteResult.Success() - - case Res.Exit(_) => - ExecuteResult.Exit - } - } + ): ExecuteResult = + execute0(ammInterp, code, inputManager, outputHandler, colors0) def currentLine(): Int = - currentLine0 + execute0.currentLine override def isComplete(code: String): Option[IsCompleteResult] = { @@ -552,24 +130,14 @@ final class ScalaInterpreter( Some(res) } - // As most "cancelled" calculations (completions, inspections, …) are run in other threads by the presentation - // compiler, they aren't actually cancelled, they'll keep running in the background. This just interrupts - // the thread that waits for the background calculation. - // Having a thread that blocks for results, in turn, is almost required by scala.tools.nsc.interactive.Response… - private val cancellableFuturePool = new CancellableFuturePool(logCtx) - - override def asyncIsComplete(code: String): Some[CancellableFuture[Option[IsCompleteResult]]] = - Some(cancellableFuturePool.cancellableFuture(isComplete(code))) - override def asyncComplete(code: String, pos: Int): Some[CancellableFuture[Completion]] = - Some(cancellableFuturePool.cancellableFuture(complete(code, pos))) - override def asyncInspect(code: String, pos: Int, detailLevel: Int): Some[CancellableFuture[Option[Inspection]]] = - Some(cancellableFuturePool.cancellableFuture(inspect(code, pos))) + override def inspect(code: String, pos: Int, detailLevel: Int): Option[Inspection] = + inspections.inspect(code, pos, detailLevel) override def complete(code: String, pos: Int): Completion = { val (newPos, completions0, _) = ammInterp.compilerManager.complete( pos, - frames0().head.imports.toString(), + (ammInterp.predefImports ++ frames0().head.imports).toString(), code ) @@ -600,82 +168,17 @@ final class ScalaInterpreter( |Ammonite ${ammonite.Constants.version} |${scala.util.Properties.versionMsg} |Java ${sys.props.getOrElse("java.version", "[unknown]")}""".stripMargin + - extraBannerOpt.fold("")("\n\n" + _), - help_links = Some(extraLinks.toList).filter(_.nonEmpty) + params.extraBannerOpt.fold("")("\n\n" + _), + help_links = Some(params.extraLinks.toList).filter(_.nonEmpty) ) - override def shutdown(): Unit = - inspectionsShutdown() - -} - -object ScalaInterpreter { - - final class PredefException( - msg: String, - causeOpt: Option[Throwable] - ) extends Exception(msg, causeOpt.orNull) { - def describe: String = - if (causeOpt.isEmpty) - s"Error while running predef: $msg" - else - s"Caught exception while running predef: $msg" - } - - // these come from Ammonite - // exception display was tweaked a bit (too much red for notebooks else) - - private def highlightFrame(f: StackTraceElement, - highlightError: fansi.Attrs, - source: fansi.Attrs) = { - val src = - if (f.isNativeMethod) source("Native Method") - else if (f.getFileName == null) source("Unknown Source") - else source(f.getFileName) ++ ":" ++ source(f.getLineNumber.toString) - - val prefix :+ clsName = f.getClassName.split('.').toSeq - val prefixString = prefix.map(_+'.').mkString("") - val clsNameString = clsName //.replace("$", error("$")) - val method = - fansi.Str(prefixString) ++ highlightError(clsNameString) ++ "." ++ - highlightError(f.getMethodName) - - fansi.Str(s" ") ++ method ++ "(" ++ src ++ ")" - } - - private def showException(ex: Throwable, - error: fansi.Attrs, - highlightError: fansi.Attrs, - source: fansi.Attrs) = { - - val cutoff = Set("$main", "evaluatorRunPrinter") - val traces = Ex.unapplySeq(ex).get.map(exception => - error(exception.toString) + newLine + - exception - .getStackTrace - .takeWhile(x => !cutoff(x.getMethodName)) - .map(highlightFrame(_, highlightError, source)) - .mkString(newLine) - ) - traces.mkString(newLine) + override def shutdown(): Unit = { + try Function.chain(ammInterp.beforeExitHooks).apply(()) + catch { + case NonFatal(e) => + log.warn("Caught exception while trying to run exit hooks", e) + } + inspections.shutdown() } - private def predef = - """import almond.api.JupyterAPIHolder.value.{ - | publish, - | commHandler - |} - |import almond.api.JupyterAPIHolder.value.publish.display - |import almond.interpreter.api.DisplayData.DisplayDataSyntax - |import almond.api.helpers.Display.{html, js, latex, markdown, text, svg, Image} - """.stripMargin - - private def error(colors: Colors, exOpt: Option[Throwable], msg: String) = - ExecuteResult.Error( - msg + exOpt.fold("")(ex => (if (msg.isEmpty) "" else "\n") + showException( - ex, colors.error(), fansi.Attr.Reset, colors.literal() - )) - ) - - private class Foo } diff --git a/modules/scala/scala-interpreter/src/main/scala/almond/ScalaInterpreterParams.scala b/modules/scala/scala-interpreter/src/main/scala/almond/ScalaInterpreterParams.scala new file mode 100644 index 000000000..af648219c --- /dev/null +++ b/modules/scala/scala-interpreter/src/main/scala/almond/ScalaInterpreterParams.scala @@ -0,0 +1,32 @@ +package almond + +import java.nio.file.Path + +import almond.protocol.KernelInfo +import ammonite.interp.{CodeClassWrapper, CodeWrapper} +import ammonite.util.Colors + +import scala.concurrent.ExecutionContext + +final case class ScalaInterpreterParams( + updateBackgroundVariablesEcOpt: Option[ExecutionContext] = None, + extraRepos: Seq[String] = Nil, + extraBannerOpt: Option[String] = None, + extraLinks: Seq[KernelInfo.Link] = Nil, + predefCode: String = "", + predefFiles: Seq[Path] = Nil, + automaticDependencies: Map[String, Seq[String]] = Map(), + forceMavenProperties: Map[String, String] = Map(), + mavenProfiles: Map[String, Boolean] = Map(), + codeWrapper: CodeWrapper = CodeClassWrapper, + initialColors: Colors = Colors.Default, + initialClassLoader: ClassLoader = Thread.currentThread().getContextClassLoader, + metabrowse: Boolean = false, + metabrowseHost: String = "localhost", + metabrowsePort: Int = -1, + lazyInit: Boolean = false, + trapOutput: Boolean = false, + disableCache: Boolean = false, + autoUpdateLazyVals: Boolean = true, + autoUpdateVars: Boolean = true +) diff --git a/modules/scala/scala-interpreter/src/main/scala/almond/internals/UpdatableFuture.scala b/modules/scala/scala-interpreter/src/main/scala/almond/internals/UpdatableFuture.scala index 30aef433f..03fe9dec8 100644 --- a/modules/scala/scala-interpreter/src/main/scala/almond/internals/UpdatableFuture.scala +++ b/modules/scala/scala-interpreter/src/main/scala/almond/internals/UpdatableFuture.scala @@ -1,7 +1,5 @@ package almond.internals -import java.util.UUID - import almond.api.JupyterApi import ammonite.repl.ReplAPI @@ -15,21 +13,26 @@ object UpdatableFuture { ec: ExecutionContext ): Unit = replApi.pprinter() = { + import jupyterApi.updatableResults._ val p = replApi.pprinter() p.copy( additionalHandlers = p.additionalHandlers.orElse { case f: Future[_] => implicit val ec0 = ec - val id = "" - jupyterApi.updatableResults.addVariable(id, "[running future]") + + val messageColor = Some(p.colorLiteral) + .filter(_ == fansi.Attrs.Empty) + .getOrElse(fansi.Color.LightGray) + + val value = updatable(messageColor("[running]").render) f.onComplete { t => - jupyterApi.updatableResults.updateVariable( - id, + update( + value, replApi.pprinter().tokenize(t).mkString, last = true ) } - pprint.Tree.Literal(id) + pprint.Tree.Literal(value) } ) } diff --git a/modules/scala/scala-interpreter/src/main/scala/almond/internals/UpdatableResults.scala b/modules/scala/scala-interpreter/src/main/scala/almond/internals/UpdatableResults.scala index bc72df3b9..3e9645b59 100644 --- a/modules/scala/scala-interpreter/src/main/scala/almond/internals/UpdatableResults.scala +++ b/modules/scala/scala-interpreter/src/main/scala/almond/internals/UpdatableResults.scala @@ -1,5 +1,7 @@ package almond.internals +import java.io.ByteArrayOutputStream +import java.nio.charset.StandardCharsets import java.util.concurrent.ConcurrentHashMap import almond.interpreter.api.DisplayData @@ -33,7 +35,7 @@ final class UpdatableResults( refs.put(k, ref) k -> vOpt.fold(v)(_._1) } - UpdatableResults.substituteVariables(data, variables0) + UpdatableResults.substituteVariables(data, variables0, isFirst = true) } } @@ -42,7 +44,7 @@ final class UpdatableResults( def updateRef(data: DisplayData, ref: Ref[Map[String, String]]): Unit = { val m0 = ref() val m = m0 + (k -> v) - val data0 = UpdatableResults.substituteVariables(data, m) + val data0 = UpdatableResults.substituteVariables(data, m, isFirst = false) log.debug(s"Updating variable $k with $v: $data0") ref() = m Future(updateData(data0))(ec) @@ -71,7 +73,7 @@ final class UpdatableResults( object UpdatableResults { - def substituteVariables(d: DisplayData, m: Map[String, String]): DisplayData = + def substituteVariables(d: DisplayData, m: Map[String, String], isFirst: Boolean): DisplayData = d.copy( data = d.data.map { case ("text/plain", t) => @@ -81,7 +83,24 @@ object UpdatableResults { // needed acc.replace(k, v) } - case kv => kv + case ("text/html", t) => + "text/html" -> m.foldLeft(t) { + case (acc, (k, v)) => + val baos = new ByteArrayOutputStream + val haos = new HtmlAnsiOutputStream(baos) + haos.write(v.getBytes(StandardCharsets.UTF_8)) + haos.close() + + val (prefix, suffix) = + if (isFirst) ("", "") + else ( + """""", + "" + ) + acc.replace(k, prefix + baos.toString("UTF-8") + suffix) + } + case kv => + kv } ) diff --git a/modules/scala/scala-interpreter/src/test/scala/almond/ScalaInterpreterTests.scala b/modules/scala/scala-interpreter/src/test/scala/almond/ScalaInterpreterTests.scala index 5d05eb9ac..04a90deb3 100644 --- a/modules/scala/scala-interpreter/src/test/scala/almond/ScalaInterpreterTests.scala +++ b/modules/scala/scala-interpreter/src/test/scala/almond/ScalaInterpreterTests.scala @@ -13,7 +13,9 @@ object ScalaInterpreterTests extends TestSuite { private val interpreter: Interpreter = new ScalaInterpreter( - initialColors = Colors.BlackWhite, + params = ScalaInterpreterParams( + initialColors = Colors.BlackWhite + ), logCtx = logCtx ) @@ -30,9 +32,11 @@ object ScalaInterpreterTests extends TestSuite { ("val n = 2", Nil) val interp = new ScalaInterpreter( - initialColors = Colors.BlackWhite, - predefCode = predefCode, - predefFiles = predefFiles, + params = ScalaInterpreterParams( + predefCode = predefCode, + predefFiles = predefFiles, + initialColors = Colors.BlackWhite + ), logCtx = logCtx ) @@ -54,9 +58,11 @@ object ScalaInterpreterTests extends TestSuite { (code, Nil) } val interp = new ScalaInterpreter( - initialColors = Colors.BlackWhite, - predefCode = predefCode, - predefFiles = predefFiles, + params = ScalaInterpreterParams( + predefCode = predefCode, + predefFiles = predefFiles, + initialColors = Colors.BlackWhite + ), logCtx = logCtx ) @@ -74,10 +80,12 @@ object ScalaInterpreterTests extends TestSuite { ("val n = 2z", Nil) val interp = new ScalaInterpreter( - initialColors = Colors.BlackWhite, - predefCode = predefCode, - predefFiles = predefFiles, - lazyInit = true, // predef throws here else + params = ScalaInterpreterParams( + predefCode = predefCode, + predefFiles = predefFiles, + initialColors = Colors.BlackWhite, + lazyInit = true // predef throws here else + ), logCtx = logCtx ) @@ -86,7 +94,7 @@ object ScalaInterpreterTests extends TestSuite { interp.execute("val m = 2 * n") false } catch { - case e: ScalaInterpreter.PredefException => + case e: AmmInterpreter.PredefException => assert(e.getCause == null) true } @@ -102,10 +110,12 @@ object ScalaInterpreterTests extends TestSuite { else ("""val n: Int = sys.error("foo")""", Nil) val interp = new ScalaInterpreter( - initialColors = Colors.BlackWhite, - predefCode = predefCode, - predefFiles = predefFiles, - lazyInit = true, // predef throws here else + params = ScalaInterpreterParams( + predefCode = predefCode, + predefFiles = predefFiles, + initialColors = Colors.BlackWhite, + lazyInit = true // predef throws here else + ), logCtx = logCtx ) @@ -114,7 +124,7 @@ object ScalaInterpreterTests extends TestSuite { interp.execute("val m = 2 * n") false } catch { - case e: ScalaInterpreter.PredefException => + case e: AmmInterpreter.PredefException => val msgOpt = Option(e.getCause).flatMap(e0 => Option(e0.getMessage)) assert(msgOpt.contains("foo")) true @@ -153,6 +163,13 @@ object ScalaInterpreterTests extends TestSuite { // (the Jupyter UI will replace some of the user code with a completion // using that parameter). + * - { + val code = "repl.la" + val expectedRes = Completion(5, 7, Seq("lastException")) + val res = interpreter.complete(code, code.length) + assert(res == expectedRes) + } + * - { val code = "Lis" val expectedRes = Completion(0, 3, Seq("List")) diff --git a/modules/scala/scala-interpreter/src/test/scala/almond/ScalaKernelTests.scala b/modules/scala/scala-interpreter/src/test/scala/almond/ScalaKernelTests.scala index a105ac4cb..329e12648 100644 --- a/modules/scala/scala-interpreter/src/test/scala/almond/ScalaKernelTests.scala +++ b/modules/scala/scala-interpreter/src/test/scala/almond/ScalaKernelTests.scala @@ -6,7 +6,7 @@ import java.util.UUID import almond.channels.Channel import almond.interpreter.Message import almond.interpreter.messagehandlers.MessageHandler -import almond.protocol._ +import almond.protocol.{Execute => ProtocolExecute, _} import almond.kernel.{ClientStreams, Kernel, KernelThreads} import almond.TestLogging.logCtx import almond.TestUtil._ @@ -40,16 +40,21 @@ object ScalaKernelTests extends TestSuite { println(s"Don't know how to shutdown $interpreterEc") } - private def execute(sessionId: String, code: String, msgId: String = UUID.randomUUID().toString) = + private def execute( + sessionId: String, + code: String, + msgId: String = UUID.randomUUID().toString, + stopOnError: Boolean = true + ) = Message( Header( msgId, "test", sessionId, - Execute.requestType.messageType, + ProtocolExecute.requestType.messageType, Some(Protocol.versionStr) ), - Execute.Request(code, stop_on_error = Some(true)) + ProtocolExecute.Request(code, stop_on_error = Some(stopOnError)) ).on(Channel.Requests) @@ -95,7 +100,9 @@ object ScalaKernelTests extends TestSuite { val streams = ClientStreams.create(input, stopWhen, inputHandler.orElse(ignoreExpectedReplies)) val interpreter = new ScalaInterpreter( - initialColors = Colors.BlackWhite, + params = ScalaInterpreterParams( + initialColors = Colors.BlackWhite + ), logCtx = logCtx ) @@ -140,7 +147,9 @@ object ScalaKernelTests extends TestSuite { val streams = ClientStreams.create(input, stopWhen) val interpreter = new ScalaInterpreter( - initialColors = Colors.BlackWhite, + params = ScalaInterpreterParams( + initialColors = Colors.BlackWhite + ), logCtx = logCtx ) @@ -195,7 +204,9 @@ object ScalaKernelTests extends TestSuite { val streams = ClientStreams.create(input, stopWhen) val interpreter = new ScalaInterpreter( - initialColors = Colors.BlackWhite, + params = ScalaInterpreterParams( + initialColors = Colors.BlackWhite + ), logCtx = logCtx ) @@ -222,7 +233,7 @@ object ScalaKernelTests extends TestSuite { val displayData = streams.displayData val expectedDisplayData = Seq( - Execute.DisplayData( + ProtocolExecute.DisplayData( Map("text/plain" -> Json.jString("Bar(other)")), Map() ) -> false @@ -247,8 +258,8 @@ object ScalaKernelTests extends TestSuite { // Initial messages from client val input = Stream( - execute(sessionId, """val handle = html("foo")"""), - execute(sessionId, """handle.update("bzz")"""), + execute(sessionId, """val handle = Html("foo")"""), + execute(sessionId, """handle.withContent("bzz").update()"""), execute(sessionId, """val s = "other"""", lastMsgId) ) @@ -256,7 +267,9 @@ object ScalaKernelTests extends TestSuite { val streams = ClientStreams.create(input, stopWhen) val interpreter = new ScalaInterpreter( - initialColors = Colors.BlackWhite, + params = ScalaInterpreterParams( + initialColors = Colors.BlackWhite + ), logCtx = logCtx ) @@ -265,22 +278,26 @@ object ScalaKernelTests extends TestSuite { t.unsafeRunTimedOrThrow() - val messageTypes = streams.generatedMessageTypes() + val requestsMessageTypes = streams.generatedMessageTypes(Set(Channel.Requests)).toVector + val publishMessageTypes = streams.generatedMessageTypes(Set(Channel.Publish)).toVector - val expectedMessageTypes = Seq( + val expectedRequestsMessageTypes = Seq( + "execute_reply", + "execute_reply", + "execute_reply" + ) + + val expectedPublishMessageTypes = Seq( "execute_input", "display_data", - "execute_result", - "execute_reply", "execute_input", "update_display_data", - "execute_reply", "execute_input", - "execute_result", - "execute_reply" + "execute_result" ) - assert(messageTypes == expectedMessageTypes) + assert(requestsMessageTypes == expectedRequestsMessageTypes) + assert(publishMessageTypes == expectedPublishMessageTypes) val displayData = streams.displayData val id = { @@ -290,15 +307,15 @@ object ScalaKernelTests extends TestSuite { } val expectedDisplayData = Seq( - Execute.DisplayData( + ProtocolExecute.DisplayData( Map("text/html" -> Json.jString("foo")), Map(), - Execute.DisplayData.Transient(Some(id)) + ProtocolExecute.DisplayData.Transient(Some(id)) ) -> false, - Execute.DisplayData( + ProtocolExecute.DisplayData( Map("text/html" -> Json.jString("bzz")), Map(), - Execute.DisplayData.Transient(Some(id)) + ProtocolExecute.DisplayData.Transient(Some(id)) ) -> true ) @@ -330,8 +347,10 @@ object ScalaKernelTests extends TestSuite { val streams = ClientStreams.create(input, stopWhen) val interpreter = new ScalaInterpreter( - updateBackgroundVariablesEcOpt = Some(bgVarEc), - initialColors = Colors.BlackWhite, + params = ScalaInterpreterParams( + updateBackgroundVariablesEcOpt = Some(bgVarEc), + initialColors = Colors.BlackWhite + ), logCtx = logCtx ) @@ -384,8 +403,10 @@ object ScalaKernelTests extends TestSuite { val streams = ClientStreams.create(input, stopWhen) val interpreter = new ScalaInterpreter( - updateBackgroundVariablesEcOpt = Some(bgVarEc), - initialColors = Colors.BlackWhite, + params = ScalaInterpreterParams( + updateBackgroundVariablesEcOpt = Some(bgVarEc), + initialColors = Colors.BlackWhite + ), logCtx = logCtx ) @@ -436,8 +457,10 @@ object ScalaKernelTests extends TestSuite { val streams = ClientStreams.create(input, stopWhen) val interpreter = new ScalaInterpreter( - updateBackgroundVariablesEcOpt = Some(bgVarEc), - initialColors = Colors.BlackWhite, + params = ScalaInterpreterParams( + updateBackgroundVariablesEcOpt = Some(bgVarEc), + initialColors = Colors.BlackWhite + ), logCtx = logCtx ) @@ -446,26 +469,37 @@ object ScalaKernelTests extends TestSuite { t.unsafeRunTimedOrThrow() - val messageTypes = streams.generatedMessageTypes() + val requestsMessageTypes = streams.generatedMessageTypes(Set(Channel.Requests)).toVector + val publishMessageTypes = streams.generatedMessageTypes(Set(Channel.Publish)).toVector + + val expectedRequestsMessageTypes = Seq( + "execute_reply", + "execute_reply", + "execute_reply", + "execute_reply" + ) - val expectedMessageTypes = Seq( + val expectedPublishMessageTypes = Seq( "execute_input", "stream", - "execute_reply", "execute_input", "display_data", - "execute_reply", "execute_input", "update_display_data", - "execute_reply", "execute_input", - "update_display_data", - "execute_reply" + "update_display_data" ) - assert(messageTypes == expectedMessageTypes) + assert(requestsMessageTypes == expectedRequestsMessageTypes) + assert(publishMessageTypes == expectedPublishMessageTypes) - val displayData = streams.displayData + val displayData = streams.displayData.map { + case (d, b) => + val d0 = d.copy( + data = d.data.filterKeys(_ == "text/plain").toMap + ) + (d0, b) + } val id = { val ids = displayData.flatMap(_._1.transient.display_id).toSet assert(ids.size == 1) @@ -473,25 +507,23 @@ object ScalaKernelTests extends TestSuite { } val expectedDisplayData = Seq( - Execute.DisplayData( + ProtocolExecute.DisplayData( Map("text/plain" -> Json.jString("a: rx.Var[Int] = 1")), Map(), - Execute.DisplayData.Transient(Some(id)) + ProtocolExecute.DisplayData.Transient(Some(id)) ) -> false, - Execute.DisplayData( + ProtocolExecute.DisplayData( Map("text/plain" -> Json.jString("a: rx.Var[Int] = 2")), Map(), - Execute.DisplayData.Transient(Some(id)) + ProtocolExecute.DisplayData.Transient(Some(id)) ) -> true, - Execute.DisplayData( + ProtocolExecute.DisplayData( Map("text/plain" -> Json.jString("a: rx.Var[Int] = 3")), Map(), - Execute.DisplayData.Transient(Some(id)) + ProtocolExecute.DisplayData.Transient(Some(id)) ) -> true ) - displayData.foreach(println) - assert(displayData == expectedDisplayData) } } @@ -518,7 +550,7 @@ object ScalaKernelTests extends TestSuite { val ignoreExpectedReplies = MessageHandler.discard { case (Channel.Publish, _) => - case (Channel.Requests, m) if m.header.msg_type == Execute.replyType.messageType => + case (Channel.Requests, m) if m.header.msg_type == ProtocolExecute.replyType.messageType => case (Channel.Control, m) if m.header.msg_type == Interrupt.replyType.messageType => } @@ -526,7 +558,7 @@ object ScalaKernelTests extends TestSuite { val stopWhen: (Channel, Message[Json]) => IO[Boolean] = (_, m) => - IO.pure(m.header.msg_type == Execute.replyType.messageType && m.parent_header.exists(_.msg_id == lastMsgId)) + IO.pure(m.header.msg_type == ProtocolExecute.replyType.messageType && m.parent_header.exists(_.msg_id == lastMsgId)) // Initial messages from client @@ -540,7 +572,9 @@ object ScalaKernelTests extends TestSuite { val streams = ClientStreams.create(input, stopWhen, interruptOnInput.orElse(ignoreExpectedReplies)) val interpreter = new ScalaInterpreter( - initialColors = Colors.BlackWhite, + params = ScalaInterpreterParams( + initialColors = Colors.BlackWhite + ), logCtx = logCtx ) @@ -601,8 +635,10 @@ object ScalaKernelTests extends TestSuite { } val interpreter = new ScalaInterpreter( - initialColors = Colors.BlackWhite, - initialClassLoader = loader, + params = ScalaInterpreterParams( + initialColors = Colors.BlackWhite, + initialClassLoader = loader + ), logCtx = logCtx ) @@ -638,7 +674,9 @@ object ScalaKernelTests extends TestSuite { val streams = ClientStreams.create(input) val interpreter = new ScalaInterpreter( - initialColors = Colors.BlackWhite + params = ScalaInterpreterParams( + initialColors = Colors.BlackWhite + ) ) val t = Kernel.create(interpreter, interpreterEc, threads) @@ -688,8 +726,10 @@ object ScalaKernelTests extends TestSuite { val streams = ClientStreams.create(input) val interpreter = new ScalaInterpreter( - initialColors = Colors.BlackWhite, - trapOutput = true + params = ScalaInterpreterParams( + initialColors = Colors.BlackWhite, + trapOutput = true + ) ) val t = Kernel.create(interpreter, interpreterEc, threads) @@ -715,6 +755,317 @@ object ScalaKernelTests extends TestSuite { assert(messageTypes == expectedMessageTypes) } + "last exception" - { + + // How the pseudo-client behaves + + val sessionId = UUID.randomUUID().toString + val lastMsgId = UUID.randomUUID().toString + + // When the pseudo-client exits + + val stopWhen: (Channel, Message[Json]) => IO[Boolean] = + (_, m) => + IO.pure(m.header.msg_type == "execute_reply" && m.parent_header.exists(_.msg_id == lastMsgId)) + + // Initial messages from client + + val input = Stream( + execute(sessionId, """val nullBefore = repl.lastException == null"""), + execute(sessionId, """sys.error("foo")""", stopOnError = false), + execute(sessionId, """val nullAfter = repl.lastException == null""", lastMsgId) + ) + + val streams = ClientStreams.create(input, stopWhen) + + val interpreter = new ScalaInterpreter( + params = ScalaInterpreterParams( + initialColors = Colors.BlackWhite + ), + logCtx = logCtx + ) + + val t = Kernel.create(interpreter, interpreterEc, threads, logCtx) + .flatMap(_.run(streams.source, streams.sink)) + + t.unsafeRunTimedOrThrow() + + val requestsMessageTypes = streams.generatedMessageTypes(Set(Channel.Requests)).toVector + val publishMessageTypes = streams.generatedMessageTypes(Set(Channel.Publish)).toVector + + val expectedRequestsMessageTypes = Seq( + "execute_reply", + "execute_reply", + "execute_reply" + ) + + val expectedPublishMessageTypes = Seq( + "execute_input", + "execute_result", + "execute_input", + "error", + "execute_input", + "execute_result" + ) + + assert(requestsMessageTypes == expectedRequestsMessageTypes) + assert(publishMessageTypes == expectedPublishMessageTypes) + + val replies = streams.executeReplies + val expectedReplies = Map( + 1 -> "nullBefore: Boolean = true", + 3 -> "nullAfter: Boolean = false" + ) + assert(replies == expectedReplies) + } + + "history" - { + + // How the pseudo-client behaves + + val sessionId = UUID.randomUUID().toString + val lastMsgId = UUID.randomUUID().toString + + // When the pseudo-client exits + + val stopWhen: (Channel, Message[Json]) => IO[Boolean] = + (_, m) => + IO.pure(m.header.msg_type == "execute_reply" && m.parent_header.exists(_.msg_id == lastMsgId)) + + // Initial messages from client + + val input = Stream( + execute(sessionId, """val before = repl.history.toVector"""), + execute(sessionId, """val a = 2"""), + execute(sessionId, """val b = a + 1"""), + execute(sessionId, """val after = repl.history.toVector.mkString(",").toString""", lastMsgId) + ) + + val streams = ClientStreams.create(input, stopWhen) + + val interpreter = new ScalaInterpreter( + params = ScalaInterpreterParams( + initialColors = Colors.BlackWhite + ), + logCtx = logCtx + ) + + val t = Kernel.create(interpreter, interpreterEc, threads, logCtx) + .flatMap(_.run(streams.source, streams.sink)) + + t.unsafeRunTimedOrThrow() + + val requestsMessageTypes = streams.generatedMessageTypes(Set(Channel.Requests)).toVector + val publishMessageTypes = streams.generatedMessageTypes(Set(Channel.Publish)).toVector + + val expectedRequestsMessageTypes = Seq( + "execute_reply", + "execute_reply", + "execute_reply", + "execute_reply" + ) + + val expectedPublishMessageTypes = Seq( + "execute_input", + "execute_result", + "execute_input", + "execute_result", + "execute_input", + "execute_result", + "execute_input", + "execute_result" + ) + + assert(requestsMessageTypes == expectedRequestsMessageTypes) + assert(publishMessageTypes == expectedPublishMessageTypes) + + val replies = streams.executeReplies + val expectedReplies = Map( + 1 -> """before: Vector[String] = Vector("val before = repl.history.toVector")""", + 2 -> """a: Int = 2""", + 3 -> """b: Int = 3""", + 4 -> """after: String = "val before = repl.history.toVector,val a = 2,val b = a + 1,val after = repl.history.toVector.mkString(\",\").toString"""" + ) + assert(replies == expectedReplies) + } + + "update vars" - { + if (AmmInterpreter.isAtLeast_2_12_7) { + + // How the pseudo-client behaves + + val sessionId = UUID.randomUUID().toString + val lastMsgId = UUID.randomUUID().toString + + // When the pseudo-client exits + + val stopWhen: (Channel, Message[Json]) => IO[Boolean] = + (_, m) => + IO.pure(m.header.msg_type == "execute_reply" && m.parent_header.exists(_.msg_id == lastMsgId)) + + // Initial messages from client + + val input = Stream( + execute(sessionId, """var n = 2"""), + execute(sessionId, """n = n + 1"""), + execute(sessionId, """n += 2""", lastMsgId) + ) + + val streams = ClientStreams.create(input, stopWhen) + + val interpreter = new ScalaInterpreter( + params = ScalaInterpreterParams( + updateBackgroundVariablesEcOpt = Some(bgVarEc), + initialColors = Colors.BlackWhite + ), + logCtx = logCtx + ) + + val t = Kernel.create(interpreter, interpreterEc, threads, logCtx) + .flatMap(_.run(streams.source, streams.sink)) + + t.unsafeRunTimedOrThrow() + + val requestsMessageTypes = streams.generatedMessageTypes(Set(Channel.Requests)).toVector + val publishMessageTypes = streams.generatedMessageTypes(Set(Channel.Publish)).toVector + + val expectedRequestsMessageTypes = Seq( + "execute_reply", + "execute_reply", + "execute_reply" + ) + + val expectedPublishMessageTypes = Seq( + "execute_input", + "display_data", + "execute_input", + "update_display_data", + "execute_input", + "update_display_data" + ) + + assert(requestsMessageTypes == expectedRequestsMessageTypes) + assert(publishMessageTypes == expectedPublishMessageTypes) + + val displayData = streams.displayData.map { + case (d, b) => + val d0 = d.copy( + data = d.data.filterKeys(_ == "text/plain").toMap + ) + (d0, b) + } + val id = { + val ids = displayData.flatMap(_._1.transient.display_id).toSet + assert(ids.size == 1) + ids.head + } + + val expectedDisplayData = List( + ProtocolExecute.DisplayData( + Map("text/plain" -> Json.jString("n: Int = 2")), + Map(), + ProtocolExecute.DisplayData.Transient(Some(id)) + ) -> false, + ProtocolExecute.DisplayData( + Map("text/plain" -> Json.jString("n: Int = 3")), + Map(), + ProtocolExecute.DisplayData.Transient(Some(id)) + ) -> true, + ProtocolExecute.DisplayData( + Map("text/plain" -> Json.jString("n: Int = 5")), + Map(), + ProtocolExecute.DisplayData.Transient(Some(id)) + ) -> true + ) + + assert(displayData == expectedDisplayData) + } + } + + "update lazy vals" - { + + // How the pseudo-client behaves + + val sessionId = UUID.randomUUID().toString + val lastMsgId = UUID.randomUUID().toString + + // When the pseudo-client exits + + val stopWhen: (Channel, Message[Json]) => IO[Boolean] = + (_, m) => + IO.pure(m.header.msg_type == "execute_reply" && m.parent_header.exists(_.msg_id == lastMsgId)) + + // Initial messages from client + + val input = Stream( + execute(sessionId, """lazy val n = 2"""), + execute(sessionId, """val a = { n; () }"""), + execute(sessionId, """val b = { n; () }""", lastMsgId) + ) + + val streams = ClientStreams.create(input, stopWhen) + + val interpreter = new ScalaInterpreter( + params = ScalaInterpreterParams( + updateBackgroundVariablesEcOpt = Some(bgVarEc), + initialColors = Colors.BlackWhite + ), + logCtx = logCtx + ) + + val t = Kernel.create(interpreter, interpreterEc, threads, logCtx) + .flatMap(_.run(streams.source, streams.sink)) + + t.unsafeRunTimedOrThrow() + + val requestsMessageTypes = streams.generatedMessageTypes(Set(Channel.Requests)).toVector + val publishMessageTypes = streams.generatedMessageTypes(Set(Channel.Publish)).toVector + + val expectedRequestsMessageTypes = Seq( + "execute_reply", + "execute_reply", + "execute_reply" + ) + + val expectedPublishMessageTypes = Seq( + "execute_input", + "display_data", + "execute_input", + "update_display_data", + "execute_input" + ) + + assert(requestsMessageTypes == expectedRequestsMessageTypes) + assert(publishMessageTypes == expectedPublishMessageTypes) + + val displayData = streams.displayData.map { + case (d, b) => + val d0 = d.copy( + data = d.data.filterKeys(_ == "text/plain").toMap + ) + (d0, b) + } + val id = { + val ids = displayData.flatMap(_._1.transient.display_id).toSet + assert(ids.size == 1) + ids.head + } + + val expectedDisplayData = List( + ProtocolExecute.DisplayData( + Map("text/plain" -> Json.jString("n: Int = [lazy]")), + Map(), + ProtocolExecute.DisplayData.Transient(Some(id)) + ) -> false, + ProtocolExecute.DisplayData( + Map("text/plain" -> Json.jString("n: Int = 2")), + Map(), + ProtocolExecute.DisplayData.Transient(Some(id)) + ) -> true + ) + + assert(displayData == expectedDisplayData) + } } } diff --git a/modules/scala/scala-kernel-api/src/main/scala/almond/api/FullJupyterApi.scala b/modules/scala/scala-kernel-api/src/main/scala/almond/api/FullJupyterApi.scala new file mode 100644 index 000000000..91ccc3aa7 --- /dev/null +++ b/modules/scala/scala-kernel-api/src/main/scala/almond/api/FullJupyterApi.scala @@ -0,0 +1,31 @@ +package almond.api + +import scala.reflect.ClassTag + +trait FullJupyterApi extends JupyterApi { self => + + protected def printOnChange[T](value: => T, + ident: String, + custom: Option[String], + onChange: Option[(T => Unit) => Unit], + onChangeOrError: Option[(Either[Throwable, T] => Unit) => Unit]) + (implicit tprint: pprint.TPrint[T], + tcolors: pprint.TPrintColors, + classTagT: ClassTag[T] = null): Iterator[String] + + protected def ansiTextToHtml(text: String): String + + object Internal { + def printOnChange[T](value: => T, + ident: String, + custom: Option[String], + onChange: Option[(T => Unit) => Unit], + onChangeOrError: Option[(Either[Throwable, T] => Unit) => Unit]) + (implicit tprint: pprint.TPrint[T], + tcolors: pprint.TPrintColors, + classTagT: ClassTag[T] = null): Iterator[String] = + self.printOnChange(value, ident, custom, onChange, onChangeOrError)(tprint, tcolors, classTagT) + def ansiTextToHtml(text: String): String = + self.ansiTextToHtml(text) + } +} diff --git a/modules/scala/scala-kernel-api/src/main/scala/almond/api/JupyterAPIHolder.scala b/modules/scala/scala-kernel-api/src/main/scala/almond/api/JupyterAPIHolder.scala index 02ae06a1e..2a093ba97 100644 --- a/modules/scala/scala-kernel-api/src/main/scala/almond/api/JupyterAPIHolder.scala +++ b/modules/scala/scala-kernel-api/src/main/scala/almond/api/JupyterAPIHolder.scala @@ -3,4 +3,4 @@ package almond.api import ammonite.runtime.APIHolder class JupyterAPIHolder -object JupyterAPIHolder extends APIHolder[JupyterApi] \ No newline at end of file +object JupyterAPIHolder extends APIHolder[FullJupyterApi] \ No newline at end of file diff --git a/modules/scala/scala-kernel-api/src/main/scala/almond/api/internal/Lazy.scala b/modules/scala/scala-kernel-api/src/main/scala/almond/api/internal/Lazy.scala new file mode 100644 index 000000000..14d124eb9 --- /dev/null +++ b/modules/scala/scala-kernel-api/src/main/scala/almond/api/internal/Lazy.scala @@ -0,0 +1,24 @@ +package almond.api.internal + +final class Lazy[T](private var compute: () => T) { + private var listeners = List.empty[Either[Throwable, T] => Unit] + def onChange: (Either[Throwable, T] => Unit) => Unit = { f => + listeners = f :: listeners + } + lazy val value: T = { + val e = + try Right(compute()) + catch { + case ex: Throwable => // catch less things here? + Left(ex) + } + listeners.foreach(_(e)) + e match { + case Right(t) => + compute = null + t + case Left(ex) => + throw ex + } + } +} diff --git a/modules/scala/scala-kernel-api/src/main/scala/almond/api/internal/Modifiable.scala b/modules/scala/scala-kernel-api/src/main/scala/almond/api/internal/Modifiable.scala new file mode 100644 index 000000000..f7e46428e --- /dev/null +++ b/modules/scala/scala-kernel-api/src/main/scala/almond/api/internal/Modifiable.scala @@ -0,0 +1,20 @@ +package almond.api.internal + +/** + * Wraps a var, allowing to notify some listeners upon change. + * + * Used by the auto-updated var-s mechanism in particular. + * + * Not thread-safe for now. + */ +final class Modifiable[T](private var value0: T) { + private var listeners = List.empty[T => Unit] + def onChange: (T => Unit) => Unit = { f => + listeners = f :: listeners + } + def value: T = value0 + def value_=(v: T): Unit = { + listeners.foreach(_(v)) + value0 = v + } +} diff --git a/modules/scala/scala-kernel-api/src/main/scala/almond/display/PrettyPrint.scala b/modules/scala/scala-kernel-api/src/main/scala/almond/display/PrettyPrint.scala new file mode 100644 index 000000000..389450b96 --- /dev/null +++ b/modules/scala/scala-kernel-api/src/main/scala/almond/display/PrettyPrint.scala @@ -0,0 +1,74 @@ +package almond.display + +import almond.api.FullJupyterApi +import ammonite.repl.ReplAPI +import ammonite.util.Ref +import pprint.PPrinter + +final class PrettyPrint private( + val value: Any, + val pprinter: Ref[PPrinter], + val fadeIn: Option[Boolean], + val ansiToHtml: String => String, + val displayId: String +) extends UpdatableDisplay { + + private def copy( + value: Any = value, + fadeIn: Option[Boolean] = fadeIn + ): PrettyPrint = + new PrettyPrint(value, pprinter, fadeIn, ansiToHtml, displayId) + + private def nextFadeInt = + if (fadeIn.isEmpty) Some(true) else fadeIn + + def withValue(value: Any): PrettyPrint = + copy(value = value, fadeIn = nextFadeInt) + def withFadeIn(): PrettyPrint = + copy(fadeIn = Some(true)) + def withFadeIn(fadeIn: Boolean): PrettyPrint = + copy(fadeIn = Some(fadeIn)) + def withFadeIn(fadeInOpt: Option[Boolean]): PrettyPrint = + copy(fadeIn = fadeInOpt) + + private def text: String = + pprinter().tokenize( + value, + height = pprinter().defaultHeight + ).map(_.render).mkString + + private def fadeIn0 = fadeIn.exists(identity) + + private def prefix = { + val extra = + if (fadeIn0) + """
""" + else + "" + extra + """" + else "" + "
" + extra + } + + private def html: String = + prefix + ansiToHtml(text) + suffix + + def data(): Map[String, String] = + Map( + Text.mimeType -> text, + Html.mimeType -> html + ) + +} + +object PrettyPrint extends { + def apply(value: Any)(implicit jupyterApi: FullJupyterApi, replApi: ReplAPI): PrettyPrint = + new PrettyPrint(value, replApi.pprinter, None, jupyterApi.Internal.ansiTextToHtml _, UpdatableDisplay.generateId()) + + def apply(value: Any, pprinter: Ref[PPrinter], ansiTextToHtml: String => String): PrettyPrint = + new PrettyPrint(value, pprinter, None, ansiTextToHtml, UpdatableDisplay.generateId()) +} diff --git a/modules/scala/scala-kernel/src/main/scala/almond/Options.scala b/modules/scala/scala-kernel/src/main/scala/almond/Options.scala index 034a72c4a..1f3a58cac 100644 --- a/modules/scala/scala-kernel/src/main/scala/almond/Options.scala +++ b/modules/scala/scala-kernel/src/main/scala/almond/Options.scala @@ -39,7 +39,11 @@ final case class Options( @HelpMessage("Trap what user code sends to stdout and stderr") trapOutput: Boolean = false, @HelpMessage("Disable ammonite compilation cache") - disableCache: Boolean = false + disableCache: Boolean = false, + @HelpMessage("Whether to automatically update lazy val-s upon computation") + autoUpdateLazyVals: Boolean = true, + @HelpMessage("Whether to automatically update var-s upon change") + autoUpdateVars: Boolean = true ) { def autoDependencyMap(): Map[String, Seq[String]] = diff --git a/modules/scala/scala-kernel/src/main/scala/almond/ScalaKernel.scala b/modules/scala/scala-kernel/src/main/scala/almond/ScalaKernel.scala index 67c0333a9..0b16ae526 100644 --- a/modules/scala/scala-kernel/src/main/scala/almond/ScalaKernel.scala +++ b/modules/scala/scala-kernel/src/main/scala/almond/ScalaKernel.scala @@ -75,14 +75,15 @@ object ScalaKernel extends CaseApp[Options] { val predefFiles = options.predefFiles() - log.info( - autoDependencies - .flatMap { - case (trigger, auto) => - Seq("Auto dependency:", s" Trigger: $trigger") ++ auto.map(dep => s" Adds: $dep") - } - .mkString("\n") - ) + if (autoDependencies.nonEmpty) + log.debug( + autoDependencies + .flatMap { + case (trigger, auto) => + Seq("Auto dependency:", s" Trigger: $trigger") ++ auto.map(dep => s" Adds: $dep") + } + .mkString("\n") + ) val interpreterEc = singleThreadedExecutionContext("scala-interpreter") @@ -97,26 +98,30 @@ object ScalaKernel extends CaseApp[Options] { else Thread.currentThread().getContextClassLoader - log.info("Creating interpreter") + log.debug("Creating interpreter") val interpreter = new ScalaInterpreter( - updateBackgroundVariablesEcOpt = Some(updateBackgroundVariablesEc), - extraRepos = options.extraRepository, - extraBannerOpt = options.banner, - extraLinks = extraLinks, - predefCode = options.predefCode, - predefFiles = predefFiles, - automaticDependencies = autoDependencies, - forceMavenProperties = forceProperties, - mavenProfiles = mavenProfiles, - initialClassLoader = initialClassLoader, - logCtx = logCtx, - metabrowse = options.metabrowse, - lazyInit = true, - trapOutput = options.trapOutput, - disableCache = options.disableCache + params = ScalaInterpreterParams( + updateBackgroundVariablesEcOpt = Some(updateBackgroundVariablesEc), + extraRepos = options.extraRepository, + extraBannerOpt = options.banner, + extraLinks = extraLinks, + predefCode = options.predefCode, + predefFiles = predefFiles, + automaticDependencies = autoDependencies, + forceMavenProperties = forceProperties, + mavenProfiles = mavenProfiles, + initialClassLoader = initialClassLoader, + metabrowse = options.metabrowse, + lazyInit = true, + trapOutput = options.trapOutput, + disableCache = options.disableCache, + autoUpdateLazyVals = options.autoUpdateLazyVals, + autoUpdateVars = options.autoUpdateVars + ), + logCtx = logCtx ) - log.info("Created interpreter") + log.debug("Created interpreter") // Actually init Ammonite interpreter in background @@ -125,9 +130,9 @@ object ScalaKernel extends CaseApp[Options] { setDaemon(true) override def run() = try { - log.info("Initializing interpreter (background)") + log.debug("Initializing interpreter (background)") interpreter.ammInterp - log.info("Initialized interpreter (background)") + log.debug("Initialized interpreter (background)") } catch { case t: Throwable => log.error(s"Caught exception while initializing interpreter, exiting", t) @@ -138,10 +143,14 @@ object ScalaKernel extends CaseApp[Options] { initThread.start() - log.info("Running kernel") - Kernel.create(interpreter, interpreterEc, kernelThreads, logCtx) - .flatMap(_.runOnConnectionFile(connectionFile, "scala", zeromqThreads)) - .unsafeRunSync() + log.debug("Running kernel") + try { + Kernel.create(interpreter, interpreterEc, kernelThreads, logCtx) + .flatMap(_.runOnConnectionFile(connectionFile, "scala", zeromqThreads)) + .unsafeRunSync() + } finally { + interpreter.shutdown() + } } } diff --git a/modules/shared/interpreter/src/main/scala/almond/interpreter/InterpreterToIOInterpreter.scala b/modules/shared/interpreter/src/main/scala/almond/interpreter/InterpreterToIOInterpreter.scala index 61c8e8f98..9e3a49ec2 100644 --- a/modules/shared/interpreter/src/main/scala/almond/interpreter/InterpreterToIOInterpreter.scala +++ b/modules/shared/interpreter/src/main/scala/almond/interpreter/InterpreterToIOInterpreter.scala @@ -64,7 +64,7 @@ final class InterpreterToIOInterpreter( IO.pure(ExecuteResult.Abort) case false => IO { - log.info(s"Executing $line") + log.debug(s"Executing $line") val res = try interpreter.execute(line, storeHistory, inputManager, outputHandler) catch { @@ -72,7 +72,7 @@ final class InterpreterToIOInterpreter( log.error(s"Error when executing $line", t) throw t } - log.info(s"Result: $res") + log.debug(s"Result: $res") res } } diff --git a/modules/shared/interpreter/src/main/scala/almond/interpreter/util/AsyncInterpreterOps.scala b/modules/shared/interpreter/src/main/scala/almond/interpreter/util/AsyncInterpreterOps.scala new file mode 100644 index 000000000..79de68ab5 --- /dev/null +++ b/modules/shared/interpreter/src/main/scala/almond/interpreter/util/AsyncInterpreterOps.scala @@ -0,0 +1,23 @@ +package almond.interpreter.util + +import almond.interpreter.{Completion, Inspection, Interpreter, IsCompleteResult} +import almond.logger.LoggerContext + +trait AsyncInterpreterOps extends Interpreter { + + def logCtx: LoggerContext + + // As most "cancelled" calculations (completions, inspections, …) are run in other threads by the presentation + // compiler, they aren't actually cancelled, they'll keep running in the background. This just interrupts + // the thread that waits for the background calculation. + // Having a thread that blocks for results, in turn, is almost required by scala.tools.nsc.interactive.Response… + private val cancellableFuturePool = new CancellableFuturePool(logCtx) + + override def asyncIsComplete(code: String): Some[CancellableFuture[Option[IsCompleteResult]]] = + Some(cancellableFuturePool.cancellableFuture(isComplete(code))) + override def asyncComplete(code: String, pos: Int): Some[CancellableFuture[Completion]] = + Some(cancellableFuturePool.cancellableFuture(complete(code, pos))) + override def asyncInspect(code: String, pos: Int, detailLevel: Int): Some[CancellableFuture[Option[Inspection]]] = + Some(cancellableFuturePool.cancellableFuture(inspect(code, pos))) + +} diff --git a/modules/scala/scala-interpreter/src/main/scala/almond/internals/CancellableFuturePool.scala b/modules/shared/interpreter/src/main/scala/almond/interpreter/util/CancellableFuturePool.scala similarity index 95% rename from modules/scala/scala-interpreter/src/main/scala/almond/internals/CancellableFuturePool.scala rename to modules/shared/interpreter/src/main/scala/almond/interpreter/util/CancellableFuturePool.scala index c312b9b55..9bfe66041 100644 --- a/modules/scala/scala-interpreter/src/main/scala/almond/internals/CancellableFuturePool.scala +++ b/modules/shared/interpreter/src/main/scala/almond/interpreter/util/CancellableFuturePool.scala @@ -1,9 +1,8 @@ -package almond.internals +package almond.interpreter.util import java.lang.Thread.UncaughtExceptionHandler import java.util.concurrent.{Executors, ThreadFactory} -import almond.interpreter.util.CancellableFuture import almond.logger.LoggerContext import scala.concurrent.{Future, Promise}